From 1576d06735cd90b1df9e2508423a902c620d14f5 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Fri, 14 Aug 2026 20:20:33 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B2=D0=BD=D1=83=D1=82=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D1=8F=D1=8F=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D1=8C=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B2=D0=BE=D0=BA=D1=80=D1=83=D0=B3=20=D0=B0=D1=83?= =?UTF-8?q?=D0=B4=D0=B8=D0=BE=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - audiorecords вместо transcribe_jobs: приложения (texts, structures, recognitions, record_events, topics) живут своими коллекциями, ссылки на исходник и на приведённую копию перестали переставляться - рубеж называет достигнутое, отказ стал признаком остановки с причиной, а сторожей стало двое: число отказов и время в рубеже - воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг выбирается по рубежу, а захват отдаёт идентификатор и признак захвата --- CLAUDE.md | 33 +- config.example.toml | 27 + ...R-2026-08-14-halt-is-a-flag-not-a-stage.md | 51 + ...-08-14-provider-payload-stored-verbatim.md | 47 + ...DR-2026-08-14-stuck-limit-stays-an-hour.md | 45 + docs/adr/README.md | 3 + docs/architecture.md | 72 +- docs/conventions/database.md | 10 +- docs/conventions/go-linters.md | 3 +- docs/conventions/logging.md | 20 +- docs/database.md | 236 ++++- docs/review.md | 21 +- docs/security.md | 43 +- go.mod | 2 +- internal/adapter/recognizer/memory.go | 71 +- .../adapter/recognizer/yandex/payload_test.go | 138 +++ .../adapter/recognizer/yandex/recognizer.go | 69 +- internal/adapter/recognizer/yandex/s3.go | 32 + .../adapter/recognizer/yandex/speechkit.go | 129 ++- internal/adapter/repo/pocketbase/file_repo.go | 52 +- .../adapter/repo/pocketbase/file_repo_test.go | 38 - .../adapter/repo/pocketbase/job_mapping.go | 214 ---- .../202608140002_record_centric_model.go | 347 ++++++ .../repo/pocketbase/migrations/migrations.go | 15 +- .../adapter/repo/pocketbase/owner_guard.go | 31 +- .../adapter/repo/pocketbase/owner_test.go | 229 ++-- internal/adapter/repo/pocketbase/panel.go | 84 +- .../adapter/repo/pocketbase/panel_test.go | 240 +++++ .../repo/pocketbase/recognition_repo.go | 161 +++ .../repo/pocketbase/record_event_repo.go | 50 + .../adapter/repo/pocketbase/record_mapping.go | 151 +++ .../adapter/repo/pocketbase/record_repo.go | 235 +++++ .../adapter/repo/pocketbase/schema_test.go | 110 ++ internal/adapter/repo/pocketbase/text_repo.go | 151 +++ .../repo/pocketbase/transcript_job_repo.go | 189 ---- .../pocketbase/transcript_job_repo_test.go | 425 -------- internal/archrules/arch_test.go | 283 ++--- internal/config/config.go | 50 + internal/config/config_test.go | 69 ++ internal/contract/contract.go | 35 +- internal/contract/repository.go | 111 +- internal/controller/http/auth_test.go | 4 +- internal/controller/http/login_test.go | 2 +- internal/controller/http/ownership_test.go | 26 +- internal/controller/http/status_test.go | 160 +++ internal/controller/http/transcribe.go | 85 +- internal/controller/http/transcribe_test.go | 69 +- internal/controller/tg/tg.go | 22 +- internal/controller/worker/pool_test.go | 129 +++ internal/controller/worker/worker.go | 75 +- internal/controller/worker/worker_test.go | 89 +- internal/entity/audio_record.go | 196 ++++ internal/entity/file.go | 25 +- internal/entity/job.go | 98 -- internal/entity/recognition.go | 46 +- internal/entity/record_event.go | 50 + internal/entity/retired_states.go | 22 + internal/entity/stage.go | 97 ++ internal/entity/structure.go | 28 + internal/entity/text.go | 28 + internal/entity/topic.go | 22 + internal/metrics/metrics.go | 9 +- internal/service/find_job_test.go | 62 +- internal/service/metrics_test.go | 83 ++ internal/service/ownership_test.go | 97 +- internal/service/pipeline_test.go | 757 +++++++++---- internal/service/recognition_test.go | 367 +++---- internal/service/shutdown_test.go | 51 +- internal/service/transcribe.go | 995 ++++++++++++------ internal/service/undelivered_test.go | 118 ++- main.go | 58 +- .../.openspec.yaml | 2 + .../2026-08-14-record-centric-model/design.md | 345 ++++++ .../proposal.md | 102 ++ .../review/triage.md | 443 ++++++++ .../specs/intake/spec.md | 168 +++ .../specs/pipeline/spec.md | 714 +++++++++++++ .../specs/recognition/spec.md | 147 +++ .../specs/storage/spec.md | 313 ++++++ .../2026-08-14-record-centric-model/tasks.md | 193 ++++ openspec/specs/intake/spec.md | 112 +- openspec/specs/pipeline/spec.md | 678 +++++++++--- openspec/specs/recognition/spec.md | 151 +++ openspec/specs/storage/spec.md | 278 +++-- 84 files changed, 8973 insertions(+), 2865 deletions(-) create mode 100644 docs/adr/ADR-2026-08-14-halt-is-a-flag-not-a-stage.md create mode 100644 docs/adr/ADR-2026-08-14-provider-payload-stored-verbatim.md create mode 100644 docs/adr/ADR-2026-08-14-stuck-limit-stays-an-hour.md create mode 100644 internal/adapter/recognizer/yandex/payload_test.go delete mode 100644 internal/adapter/repo/pocketbase/file_repo_test.go delete mode 100644 internal/adapter/repo/pocketbase/job_mapping.go create mode 100644 internal/adapter/repo/pocketbase/migrations/202608140002_record_centric_model.go create mode 100644 internal/adapter/repo/pocketbase/panel_test.go create mode 100644 internal/adapter/repo/pocketbase/recognition_repo.go create mode 100644 internal/adapter/repo/pocketbase/record_event_repo.go create mode 100644 internal/adapter/repo/pocketbase/record_mapping.go create mode 100644 internal/adapter/repo/pocketbase/record_repo.go create mode 100644 internal/adapter/repo/pocketbase/schema_test.go create mode 100644 internal/adapter/repo/pocketbase/text_repo.go delete mode 100644 internal/adapter/repo/pocketbase/transcript_job_repo.go delete mode 100644 internal/adapter/repo/pocketbase/transcript_job_repo_test.go create mode 100644 internal/controller/http/status_test.go create mode 100644 internal/controller/worker/pool_test.go create mode 100644 internal/entity/audio_record.go delete mode 100644 internal/entity/job.go create mode 100644 internal/entity/record_event.go create mode 100644 internal/entity/retired_states.go create mode 100644 internal/entity/stage.go create mode 100644 internal/entity/structure.go create mode 100644 internal/entity/text.go create mode 100644 internal/entity/topic.go create mode 100644 internal/service/metrics_test.go create mode 100644 openspec/changes/archive/2026-08-14-record-centric-model/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-14-record-centric-model/design.md create mode 100644 openspec/changes/archive/2026-08-14-record-centric-model/proposal.md create mode 100644 openspec/changes/archive/2026-08-14-record-centric-model/review/triage.md create mode 100644 openspec/changes/archive/2026-08-14-record-centric-model/specs/intake/spec.md create mode 100644 openspec/changes/archive/2026-08-14-record-centric-model/specs/pipeline/spec.md create mode 100644 openspec/changes/archive/2026-08-14-record-centric-model/specs/recognition/spec.md create mode 100644 openspec/changes/archive/2026-08-14-record-centric-model/specs/storage/spec.md create mode 100644 openspec/changes/archive/2026-08-14-record-centric-model/tasks.md create mode 100644 openspec/specs/recognition/spec.md diff --git a/CLAUDE.md b/CLAUDE.md index b2cc76f..9475835 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,15 +72,30 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project- Само имя — последняя часть ссылки `/api/files/...`, поэтому в журнал пишется расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой записи. **critical** -- **Колонки очереди правятся в четырёх местах** пакета хранилища — - `applyToRecord`, `recordToJob`, константа `acquireColumns` и структура - `acquiredRow` с её `toJob`, — плюс шаг схемы. Компилятор видит два из них. - Колонка, забытая в паре `acquireColumns`/`acquiredRow`, приезжает из захвата - нулевой, и первый же `Save` пишет этот ноль поверх сохранённого значения: - поле теряется **только у задачи, попавшей к воркеру**. **major** -- **Результат пишет только держатель захвата.** Шаг, чей захват за время работы - достался другому, завершается без записи и без ответа отправителю. Иначе два - воркера пишут в одну задачу по очереди, а отправитель получает два ответа. +- **Колонки записи правятся в двух местах** пакета хранилища — + `applyOwnedByPipeline` вместе с `applyToRecord` и `recordToAudioRecord`, — + плюс шаг схемы. Компилятор не видит ни одного: колонка, забытая в одном из + них, теряется молча — запись сохранится без поля либо приедет с нулевым. + Мест было четыре, пока захват перечислял колонки поимённо; теперь он + возвращает идентификатор и признак своего захвата, и перечень перестал расти + с моделью. Сверку держат правила `internal/archrules`. **major** +- **Рубеж объявляется одним дескриптором** — `internal/entity/stage.go`. Из него + выводятся выбор шага, отбор захвата, срок протухания захвата и предел простоя; + перечислять рубежи порознь в каждом потребителе нельзя. Рубеж, забытый в + отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту + ниже не пишется в журнал и не считается в метрику: запись встанет без единого + следа. Сверку держат правила `internal/archrules`. **major** +- **Результат пишет только держатель захвата, и держатель узнаётся значением.** + Признак захвата уникален для каждого захвата, и запись результата условна по + нему, а не по занятости записи. Шаг, чей захват за время работы достался + другому — по протуханию срока или после того, как человек снял признак + остановки в панели, — завершается без записи и без ответа отправителю. Условие + по непустоте признака пропустило бы обоих: два воркера писали бы в одну запись + по очереди, а отправитель получал бы два ответа. **major** +- **Остановленная запись сообщает отправителю, какой бы ни была причина.** + Причин три — приговор шага, исчерпанные отказы, застревание. Остановленная + запись захвату не выдаётся, значит исход «пригодна к повтору» исключён. + Обязанность, записанная у одной причины, у остальных читалась бы как снятая. **major** ## Команды diff --git a/config.example.toml b/config.example.toml index 6951b3a..f5f956c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -9,6 +9,33 @@ force_shutdown_timeout = 20 [storage] data_dir = "data" +# Конвейер расшифровки. +[pipeline] +# Число рабочих потоков. Специализации у них нет: каждый берёт любую пригодную к +# работе запись и выбирает шаг по её рубежу. +# +# Ноль — законное значение, а не поломка: сервис поднимается, записи +# принимаются и не двигаются. Годится местному запуску и выкладке, где конвейер +# надо остановить, не роняя приём. +workers = 3 + +# Предел простоя записи там, где работу делаем мы сами, в минутах. +# +# Сторож ловит **зависание**, а не долгую работу: пока шаг идёт, запись занята +# захватом, и живой процесс наблюдается сам по себе. Час меньше времени, которое +# многочасовая запись занимает на приведении, и это принято сознательно +# (решение владельца 2026-08-14): цена ложной остановки — одно движение +# владельца, потому что остановка обратима и рубежа не стирает. +own_work_limit_minutes = 60 + +# Предел простоя там, где ждём операцию внешнего сервиса, в минутах. +# +# Сколько идёт распознавание долгой записи, никто не мерил, поэтому ошибаемся в +# сторону долгого: ложная остановка хуже поздней. Откладывание опроса этот +# отсчёт не двигает — иначе зависшая у провайдера операция опрашивалась бы +# вечно. +foreign_work_limit_minutes = 1440 + # Yandex Cloud Configuration [yandex] # ID папки в Yandex Cloud (получить в консоли Yandex Cloud) diff --git a/docs/adr/ADR-2026-08-14-halt-is-a-flag-not-a-stage.md b/docs/adr/ADR-2026-08-14-halt-is-a-flag-not-a-stage.md new file mode 100644 index 0000000..b3b72e3 --- /dev/null +++ b/docs/adr/ADR-2026-08-14-halt-is-a-flag-not-a-stage.md @@ -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`, и довод был тот +же — «два способа вывести задачу из выборки расходятся». Довод устоял, а +носитель сменился: теперь единственный способ вывести запись из выборки — этот +признак, и состояние его больше не дублирует. + +## Последствия + +- `+` перезапуск перестал быть догадкой: запись продолжает с сохранённого + рубежа, а не начинает конвейер заново. +- `+` новая причина остановки стоит значения в закрытом перечне причин, а не + нового состояния и не нового шага схемы. +- `+` массовый возврат в работу после выкатки правки делается одним обновлением + колонки. +- `−` в выборке захвата появилось четвёртое условие, и рубеж перестал быть + единственным, что выводит запись из работы: читать состояние записи теперь + надо двумя полями. +- `−` перечень причин закрыт схемой, то есть новая причина всё же требует шага + схемы — дешевле прежнего, но не бесплатно. diff --git a/docs/adr/ADR-2026-08-14-provider-payload-stored-verbatim.md b/docs/adr/ADR-2026-08-14-provider-payload-stored-verbatim.md new file mode 100644 index 0000000..86232b0 --- /dev/null +++ b/docs/adr/ADR-2026-08-14-provider-payload-stored-verbatim.md @@ -0,0 +1,47 @@ +# Ответ распознавателя хранится дословно, двоичной формой и вложением + +- **Дата:** 2026-08-14 +- **Источник:** openspec/changes/archive/2026-08-14-record-centric-model/design.md, + раздел «Сырой ответ провайдера хранится вложением, а не колонкой» + +## Решение + +Ответ SpeechKit сохраняется целиком — сообщения потока подряд, каждое своей +двоичной записью с длиной впереди, — и лежит **вложением** коллекции попыток +распознавания, а не колонкой. + +## Почему + +Цитата источника: + +> Хранится он вообще потому, что **результат операции у провайдера не +> переспрашивается**. Отвергнутый вариант — не хранить и разобрать на лету: +> дешевле сегодня, но связь реплики с говорящим мы строить пока не умеем, и +> когда научимся, архив пересчитать будет не из чего, а повторная операция стоит +> денег за каждую запись. + +Вложением, а не колонкой: + +> Ответ на многочасовую запись — мегабайты. Хранилище читает запись целиком, а +> шаг опроса читает строку попытки раз в несколько секунд: положенный колонкой, +> ответ ехал бы в память при каждом опросе — тот же промах, что расшифровка в +> перечне колонок захвата сегодня. + +Двоичной формой, а не текстовой, — решение ревью кода того же изменения. Замер: +текстовое представление собирается по нашей скомпилированной схеме и **молча +выбрасывает поля, которых в ней нет**, а провайдер добавляет их без +предупреждения. Двоичная форма неизвестные поля переносит: они переживают запись +и чтение и станут читаемыми, когда схема обновится. Ради этого архив и заводился. + +## Последствия + +- `+` архив пересчитывается из сохранённого без единого рубля: связь реплики с + говорящим станет доступна, когда мы научимся её читать. +- `+` шаг опроса читает строку попытки, не поднимая мегабайты в память. +- `−` **формат файла на диске объявлен необратимым**: сохранённое не читается + глазами и не разбирается ничем, кроме нашего же кода, а прочесть архив без + сервиса нельзя вовсе. +- `−` каталог данных растёт быстрее прежнего: ответ многословнее самой + расшифровки — несёт альтернативы, время каждого слова и разбор говорящих. + Потолок в 256 МиБ на вложение назван строкой в `database.md`, а сколько там на + деле у шестичасовой записи, не мерил никто. diff --git a/docs/adr/ADR-2026-08-14-stuck-limit-stays-an-hour.md b/docs/adr/ADR-2026-08-14-stuck-limit-stays-an-hour.md new file mode 100644 index 0000000..9e8494b --- /dev/null +++ b/docs/adr/ADR-2026-08-14-stuck-limit-stays-an-hour.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`. diff --git a/docs/adr/README.md b/docs/adr/README.md index 49d3a7d..1c846e8 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.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-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) | | diff --git a/docs/architecture.md b/docs/architecture.md index c2fa5cd..4e6fef4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -32,6 +32,11 @@ - [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage` 2026-08-12; +- [recognition](../openspec/specs/recognition/spec.md) — **попытка распознавания + у внешнего провайдера**: что о ней хранится, почему сырой ответ сохраняется + целиком и вложением, как из сохранённого строится структура реплик без + повторной оплаты и почему разбор формата провайдера не доходит до конвейера. + Задача `record-centric-model` 2026-08-14; - [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что её прекращает и какие адреса остаются открытыми. Задача `oidc-login` @@ -78,22 +83,22 @@ | --- | --- | --- | | Telegram-бот | `internal/controller/tg` | Принимает голосовые, аудиофайлы и документы с аудио, скачивает их, заводит задачу | | HTTP API | `internal/controller/http` | Приём файла и опрос статуса задачи | -| Воркеры | `internal/controller/worker` | Крутят по одному шагу конвейера, опрашивая базу | -| Сервис расшифровки | `internal/service` | Конвейер: приём, конвертация, распознавание, отдача результата | +| Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен | +| Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи | | Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности | -| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit | +| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit; разбор ответа в реплики со временем | | Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам | -| Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом | +| Репозитории | `internal/adapter/repo/pocketbase` | Записи, файлы, тексты, структура, попытки распознавания и журнал событий — коллекциями хранилища; захват — сырым запросом | | Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций | -| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки задачи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» | +| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки записи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» | - - -Конвейер: `created` → `converted` → `transcribe` → `done` либо `failed`. Каждый -переход двигает свой воркер, и каждый опрашивает базу раз в секунду. Что -делает задача, исчерпавшая попытки, нормирует -[pipeline](../openspec/specs/pipeline/spec.md), «Число попыток и состояние -«мертва»». +Цепочка рубежей — `uploaded` → `normalized` → `submitted` → `transcribed` → +`done`; рубеж называет достигнутое, а не предстоящее, и нормирует его +[pipeline](../openspec/specs/pipeline/spec.md), «Рубеж записи называет +достигнутое». Отказ рубежом не является: он ставит признак остановки, а рубеж +сохраняется — там же, «Остановка записи — признак, а не рубеж». Шаг выбирается +по рубежу одним местом, воркеры к шагам не привязаны, а их число приходит +настройкой. ## Внешние границы и форматы @@ -136,6 +141,17 @@ поделят один длинный опрос и часть ответов до людей не дойдёт. Ревью кода воспроизвело порядок на прежней версии, живой прогон — на нынешней. +- **Откат образа через шаг схемы `202608140002` не работает и не говорит об + этом.** Шаг удаляет прежнюю коллекцию задач, а библиотека накатывает только + те шаги, которые знает сам бинарь: прежний образ шагов новее не видит, + поднимается **без единой ошибки** и отвечает зелёной пробой здоровья — после + чего всякое обращение к очереди отказывает «коллекции нет». Проверено прогоном + двух бинарей на одном каталоге данных. + + Значит штатное средство владельца на инциденте — «вернём прошлый образ» — с + этого шага делает хуже и молчит. Лечится повторной выкладкой нового образа; + обратного шага схемы нет и не планируется. Порог перехода назван прямо: до + выкладки `record-centric-model` откат образа работает, после — нет. - **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает медленно» читается вместе с тем, что таймаута нет ни у одного обращения наружу — [database.md](database.md), «Настройки с числовым значением»: @@ -145,17 +161,23 @@ | Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор | | --- | --- | --- | --- | --- | | Telegram Bot API | Сервис поднимается без Telegram и работает по HTTP; старт роняют только ошибки настройки — ответ «такого бота нет» и включённый вход с пустым ключом доступа. Норму держит [intake](../openspec/specs/intake/spec.md), «Признак включения решает, поднимается ли вход Telegram» | На старте — ждём не дольше срока, дальше поднимаемся без Telegram. У поднятого сервиса скачивание файла висит бесконечно: там срока нет | То же, что «отвечает медленно»: на старте — подъём без Telegram по истечении срока, у поднятого — длинный опрос пуст и новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации | - | Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» | + | Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись завершается заглушкой «на записи нет текста» | | ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — | - | Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции | - | ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла». Остановка сервиса — исход другой: процесс убивают контекстом, и задача остаётся на повтор, не тратя попытки | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании | + | Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции | + | ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании | | Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — | | Диск | Запись файла падает, задача не заводится | — | — | — | - **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота или по сообщению об ошибке. Владелец — по метрике - `transcriber_worker_job_count` с меткой `error="true"` и по логам контейнера. - Отдельного оповещения нет. + `transcriber_worker_job_count` с меткой `error="true"`, и метка `stage` + называет рубеж, с которого запись взята: с появлением пула одинаковых воркеров + имя потока перестало что-либо значить, а разрез по шагу — единственное, чем + «падает приведение» отличается от «падает распознавание». Плюс логи + контейнера. Отдельного оповещения нет. +- **Журнал событий записи** — второй канал наблюдения, `record_events`. Пишется + на смену рубежа, на остановку и на снятие остановки; читает его человек в + панели, ни один шаг конвейера на него не смотрит. Экрана у него пока нет. - **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос, воркеры опрашивают базу вхолостую с паузой из [database.md](database.md), «Настройки с числовым значением». @@ -164,12 +186,15 @@ | Что | Где | | --- | --- | -| Приём аудио и заведение задачи | `TranscribeService.createTranscribeJob` — через него идут оба входа | -| Правка задачи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает | -| Захват задачи воркером | `TranscriptJobRepository.FindAndAcquire` — один запрос с `RETURNING` | +| Приём аудио и заведение записи | `TranscribeService.createRecord` — через него идут оба входа | +| Правка записи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает | +| Захват записи воркером | `AudioRecordRepository.FindAndAcquire` — один запрос с `RETURNING`, отдаёт идентификатор и признак захвата | +| Объявление рубежа | `internal/entity/stage.go` — выбор шага, отбор захвата, срок протухания и предел простоя выводятся отсюда | +| Выбор шага по рубежу | `TranscribeService.stepFor` — таблица, а не привязка к воркеру | | Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг | -| Переход задачи в состояние | `entity.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния | -| Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю | +| Переход записи на рубеж | `entity.AudioRecord.MoveToState` — чистит служебные поля прошлого рубежа и ставит время входа | +| Откладывание работы | `entity.AudioRecord.Postpone` — ставит паузу и снимает захват, рубежа не трогая | +| Остановка и перезапуск | `entity.AudioRecord.Halt` и `Resume`; ответ отправителю — `TranscribeService.halt`, одно место на все причины | | Разбор конфигурации | `internal/config.LoadConfig` | | Чтение времени | `internal/clock` — `Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера | | Метрики | `internal/metrics`, префикс имени `transcriber_` | @@ -243,7 +268,8 @@ - **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но конвертер этот случай не проверялся. - **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12 - ([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)) и нормирована + ([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)), перестроена + вокруг аудиозаписи задачей `record-centric-model` 2026-08-14 и нормирована спекой `pipeline`. Не решено, отказываться ли от холостого опроса: он даёт сотни тысяч запросов к базе в сутки — расчёт из числа воркеров и их паузы, а не замер diff --git a/docs/conventions/database.md b/docs/conventions/database.md index 0b648b6..38eb4cd 100644 --- a/docs/conventions/database.md +++ b/docs/conventions/database.md @@ -49,10 +49,12 @@ - Enum-поля (`state`, `source`, …) — обычный `TEXT` без `CHECK`; допустимые значения держит код. - *Расхождение:* перечень состояний задачи закрыт схемой (`SelectField`), а не - кодом — ради панели владельца: правка руками не должна заводить состояние, - которого конвейер не знает. Цена названа: шестое состояние потребует нового - шага схемы. + *Расхождение:* перечни, по которым панель владельца правит запись руками, + закрыты схемой (`SelectField`), а не кодом: правка руками не должна заводить + значение, которого сервис не знает. Закрыты рубеж записи, причина её + остановки, вид текста, источник и исход события журнала. Цена названа: новое + значение любого из них потребует нового шага схемы, а применённый шаг не + переписывается. Прочие перечни остаются обычным `TEXT`. - Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например `2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`). diff --git a/docs/conventions/go-linters.md b/docs/conventions/go-linters.md index af29117..19a11ac 100644 --- a/docs/conventions/go-linters.md +++ b/docs/conventions/go-linters.md @@ -91,7 +91,8 @@ | Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules` → `TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` | | Транспорты (`controller/http`, `controller/tg`, `controller/worker`) не знают друг о друге | `internal/archrules` → `TestТранспортыНеЗнаютДругОДруге` | | Адаптер не знает ни ядра, ни транспортов | `internal/archrules` → `TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` | -| Колонки очереди согласованы: перечень захвата ↔ структура захвата ↔ шаг схемы ↔ запись коллекции ↔ перенос поля в задачу | `internal/archrules` → правила о захвате. Закрывает инвариант «колонки правятся в четырёх местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций: по файлу целиком условие выполнялось бы тегами `db:"…"` самой структуры, и правило было бы зелёным всегда | +| Колонки записи согласованы: что пишет отображение ↔ что читает обратное ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках. Закрывает инвариант «колонки записи правятся в двух местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций, а не в файле целиком | +| Рубежи согласованы: дескриптор ↔ таблица выбора шага, в обе стороны | `internal/archrules` → правила о рубежах. Закрывает инвариант «рубеж объявляется одним дескриптором» (CLAUDE.md, major). Рубеж без шага останавливает запись, не начав работы; шаг без рубежа недостижим — захват такую запись не выдаст никогда | ### Отмена и внешний собеседник diff --git a/docs/conventions/logging.md b/docs/conventions/logging.md index ff19daa..783c1d3 100644 --- a/docs/conventions/logging.md +++ b/docs/conventions/logging.md @@ -28,22 +28,22 @@ OpenSpec. `jq` без регулярных выражений. ```json -{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"job accepted","capability":"intake","job_id":"…","source":"telegram","duration_seconds":137} +{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"record accepted","capability":"intake","record_id":"…","source":"telegram","duration_seconds":137} ``` *Расхождение:* `main.go` ставит `slog.NewTextHandler(os.Stdout, …)`. ## Сообщение -- `msg` — короткая константа в нижнем регистре: `job accepted`, +- `msg` — короткая константа в нижнем регистре: `record accepted`, `recognition done`, `conversion failed`. Данные — в атрибутах: - `log.Info("job accepted", "job_id", id, "source", "telegram")`. + `log.Info("record accepted", "record_id", id, "source", "telegram")`. - `msg` — чистая категория без префикса подсистемы: `recognition done`, а не `recognize: done`. Подсистему выносим в поле `capability`, не в текст. - **Смена состояния задачи — единая категория `state transition`** с полями `from`, `to` и причиной. Любой переход пишет этот `msg`, чтобы весь жизненный цикл собирался одним отбором: - `jq 'select(.msg=="state transition" and .job_id=="…")'`. Физический эффект + `jq 'select(.msg=="state transition" and .record_id=="…")'`. Физический эффект сверх перехода — отдельная запись своей категории (`file converted`, `text delivered`), она запись перехода не подменяет. @@ -100,13 +100,13 @@ OpenSpec. | Когда добавляем | Поля | | --- | --- | | на входящий HTTP-запрос | `transport` (`http`, `telegram`), `http.method`, `http.route`, `http.status_code`, `duration_ms` | -| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `job_id`, `file_id`, `source` | +| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` | | на запись об ошибке | `error` | | на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` | Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум. -*Расхождение:* в коде встречаются `job_id`, `file_id`, `operation_id`, +*Расхождение:* в коде встречаются `record_id`, `file_id`, `operation_id`, `worker`, `path`, `src_path`, `dest_path` — то есть словарь сложился сам и пересечён с этим лишь частично. @@ -120,16 +120,16 @@ OpenSpec. чтобы ключ дописывался на каждую запись сам: ```go -log := log.With("job_id", job.Id, "capability", "conversion") +log := log.With("record_id", record.Id, "capability", "conversion") ``` - Все записи одной задачи собираются одним отбором: - `jq 'select(.job_id=="…")' app.jsonl`. + `jq 'select(.record_id=="…")' app.jsonl`. ## Ошибки Ошибки Go логируем как атрибут, а не как текст сообщения: -`log.Error("conversion failed", "error", err, "job_id", id)`. Ключ — `error`. +`log.Error("conversion failed", "error", err, "record_id", id)`. Ключ — `error`. - Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя — контекст @@ -277,5 +277,5 @@ Bot API, поэтому чистка на месте употребления з ## Анализ -- Повседневно — `jq`: `jq 'select(.job_id=="…")' app.jsonl`. +- Повседневно — `jq`: `jq 'select(.record_id=="…")' app.jsonl`. - Тяжёлое (сведение, соединение) — DuckDB поверх JSONL прямо из файла. diff --git a/docs/database.md b/docs/database.md index 43e130e..2678f45 100644 --- a/docs/database.md +++ b/docs/database.md @@ -38,85 +38,191 @@ CGO сборке не нужен. ### `files` -Один файл на одну физическую копию: исходник, результат конвертации и копия в -Object Storage — каждая своей записью. +Одна запись на одну физическую копию. Копий у аудиозаписи ровно две: принятая и +приведённая к рабочему формату. Копия во внешнем хранилище файлом записи не +считается — она существует только потому, что провайдер распознавания читает +аудио по адресу, и её ключ живёт в строке попытки распознавания. | Поле | Тип | Что | | --- | --- | --- | | `id` | TEXT PK | Идентификатор записи, выдаёт хранилище | -| `file` | file | Сам файл; пусто у копии в Object Storage | +| `file` | file | Сам файл | | `owner` | relation → `users` | Владелец файла; пусто у файлов записи, принятой ботом | | `location` | select | `local` или `s3` | -| `object_key` | TEXT | Ключ объекта; пусто у местной копии | +| `object_key` | TEXT | Ключ объекта; заведён прежним шагом и новым путём не заполняется | | `size` | INTEGER | Размер в байтах | +| `format` | TEXT | Расширение без точки, в нижнем регистре | +| `duration_ms` | INTEGER | Длительность, если её удалось прочитать | | `created`, `updated` | DATETIME | Проставляет хранилище | Поле названо `location`, а не `storage`: последним словом зовут само хранилище и capability, и третий смысл развёл бы одно слово по разным вещам. -### `transcribe_jobs` +### `audio_records` -Задача расшифровки и она же очередь. +Аудиозапись — центральная сущность сервиса. Домен, поля очереди и ссылки на +приложения лежат здесь; содержимое — по ссылкам, отдельными строками. | Поле | Тип | Что | | --- | --- | --- | | `id` | TEXT PK | Идентификатор записи, выдаёт хранилище | | `owner` | relation → `users` | Владелец записи; пусто у записей, принятых ботом | -| `state` | select | `created`, `converted`, `transcribe`, `done`, `failed`, `dead`; перечень закрыт схемой | | `source` | select | `api`, `telegram`, `unknown` | -| `file` | relation → `files` | **Текущий** файл задачи: шаг конвейера переставляет ссылку на свой результат | -| `delay_time` | DATETIME | Не брать задачу раньше этого времени | -| `acquisition_id` | TEXT | Кто захватил задачу | -| `acquire_time` | DATETIME | Когда захватил; по нему считается протухание | -| `attempts` | INTEGER ≥ 0 | Число попыток: растёт при захвате, обнуляется на шаге без отказа | -| `recognition_op_id` | TEXT | Идентификатор операции в Yandex Cloud | -| `transcription_text` | editor | Результат распознавания | +| `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком | +| `state` | select | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done`; перечень закрыт схемой | +| `state_entered_at` | DATETIME | Время входа в рубеж — сторож застревания | +| `halted_at` | DATETIME | Признак остановки; рубеж при ней не стирается | +| `halt_reason` | select | `step_failed`, `attempts_exhausted`, `stuck` | | `error_text` | TEXT | Текст ошибки, машинный | +| `acquisition_id` | TEXT | Признак **этого** захвата, уникальный для каждого | +| `acquire_expires_at` | DATETIME | Срок протухания захвата; приезжает с рубежом | +| `delay_time` | DATETIME | Не брать запись раньше этого времени | +| `attempts` | INTEGER ≥ 0 | Число **отказов**: растёт при захвате, обнуляется на шаге без отказа и на откладывании | +| `original_file` | relation → `files` | Принятая копия | +| `normalized_file` | relation → `files` | Копия, приведённая к рабочему формату | +| `transcript_text`, `literary_text` | relation → `texts` | Тексты записи | +| `structure` | relation → `structures` | Структура реплик | +| `recognition` | relation → `recognitions` | Попытка распознавания | +| `topics` | relation → `topics`, до 5 | Темы записи | | `tg_chat_id` | INTEGER | Куда отправить результат | | `tg_reply_message_id` | INTEGER | С каким сообщением связать | | `created`, `updated` | DATETIME | Проставляет хранилище | -Индекс один — по `state`: выборка воркера идёт по нему, паузе и сроку захвата. -Прежней колонки `is_error` нет: задача выбывает из выборки состоянием, и способ -этот один. +Индекс один — по паре «рубеж и признак остановки»: выборка захвата идёт по ним, +паузе и сроку протухания. -**Состояния `failed` и `dead` — разные приговоры**, и чей это приговор, нормирует -[pipeline](../openspec/specs/pipeline/spec.md), «Число попыток и состояние -«мертва»». Схеме принадлежит только закрытость перечня: шестое состояние -потребует нового шага. +**Ссылки на файлы две и порознь.** Прежняя модель держала одну и переставляла её +каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем +хранилище, и принятого человеком файла не найти было ничем. + +**Остановка — признак, а не рубеж.** Прежние состояния `failed` и `dead` +схлопнуты в `halted_at` с причиной: обе восстанавливаются одинаково — снятием +признака, — и различие между ними перестало быть структурным. Рубеж при +остановке сохраняется, поэтому запись продолжает с места остановки. + +**Сторожей двое.** `attempts` ограничивает повторы внутри шага, +`state_entered_at` — застревание. Прежде обе обязанности несло одно число, и не +справлялось ни с одной. + +### `texts` + +| Поле | Тип | Что | +| --- | --- | --- | +| `record` | relation → `audio_records` | Чья это расшифровка | +| `kind` | select | `transcript` или `literary` | +| `contents` | editor | Сам текст | + +Пара «запись и вид» уникальна: повтор прерванного шага не заводит второй строки. +Поле зовётся `kind`, а не `format`: словом `format` в этой же схеме зовут формат +файла. + +### `structures` + +| Поле | Тип | Что | +| --- | --- | --- | +| `record` | relation → `audio_records` | Чья это структура | +| `version` | INTEGER | Версия вида разбора | +| `contents` | JSON | Реплики со временем | + +Пара «запись и версия разбора» уникальна. Номер версии нужен потому, что разбор +сохранённого ответа изменится раньше, чем архив пересчитают. + +### `recognitions` + +Попытка распознавания у внешнего провайдера — всё, что зависит от него. + +| Поле | Тип | Что | +| --- | --- | --- | +| `record` | relation → `audio_records` | Чья это попытка | +| `provider`, `model` | TEXT | Кем и какой моделью считано | +| `external_id` | TEXT | Идентификатор операции у провайдера | +| `source_uri` | TEXT | Адрес, по которому провайдер читает аудио | +| `payload` | file, **защищённое** | Сырой ответ провайдера целиком | +| `started_at`, `finished_at` | DATETIME | Границы операции | + +**Сырой ответ лежит вложением, а не колонкой.** Шаг опроса читает эту строку раз +в несколько секунд, а хранилище читает запись целиком: ответ на многочасовую +запись ехал бы в память при каждом опросе. Хранится он потому, что результат +операции у провайдера не переспрашивается. + +Поле вложения помечено защищённым: сырой ответ — это полный текст речи, и +умолчание библиотеки отдавало бы его по ссылке любому, кто её знает. + +### `record_events` + +| Поле | Тип | Что | +| --- | --- | --- | +| `record` | relation → `audio_records` | Чьё это событие | +| `origin` | select | `pipeline` или `human` | +| `step` | TEXT | Имя шага | +| `outcome` | select | `done`, `failed`, `halted`, `resumed` | +| `outcome_text` | TEXT | Причина, если она есть | +| `duration_ms` | INTEGER | Сколько шаг занял | + +Колонка текста зовётся `outcome_text`, а не `error_text`: последнее имя названо +поимённо инвариантом о секрете, и две колонки с этим именем сделали бы инвариант +двусмысленным. + +Журнал пишется на смену рубежа, на остановку и на снятие остановки — не на +каждое откладывание опроса. Ни один шаг конвейера его не читает, чтобы решить, +что делать дальше. + +### `topics` + +| Поле | Тип | Что | +| --- | --- | --- | +| `owner` | relation → `users` | Чей это словарь | +| `name` | TEXT | Название темы | + +Пара «владелец и название» уникальна: словарь тем свой у каждого человека. +Коллекцией, а не набором строк в записи, потому что перечень тем нужен целиком +перед каждым обращением к языковой модели. Ни один шаг сегодняшнего сервиса тем +не пишет и не читает — место заведено вперёд, чтобы задача, считающая темы, не +платила вторым необратимым шагом схемы. + +### Чего в схеме больше нет + +Коллекция `transcribe_jobs` удалена шагом `202608140002`. Данных под ней не было: +сервис на сервере остановлен, а прежние записи удалены решением владельца +2026-08-14 — переноса это изменение не делало. Оставленная пустая коллекция +висела бы в панели вторым домом для понятия, которого больше нет. **Владелец записи** заведён шагом `202608140001` — связью с коллекцией `users` в обеих таблицах. Пустое значение допустимо, и это решение с ценой: записи, принятые ботом, владельца не имеют вовсе, потому что связи чата Telegram с -учётной записью сервис не ведёт. Обязательность для приёма по HTTP держит -поэтому сам приём, а не схема. +учётной записью сервис не ведёт. Обязательность для приёма по HTTP держит поэтому +сам приём, а не схема. -Выборка по владельцу сужает **чтение задачи**: чужая, ничья и несуществующая +Выборка по владельцу сужает **чтение записи**: чужая, ничья и несуществующая дают один и тот же отказ. Выборку воркера владелец не сужает — конвейер -обрабатывает записи всех. Тот же шаг сужает правило просмотра коллекции -`files` владельцем: прежнее правило пускало всякого вошедшего, и знание -идентификатора файловой записи равнялось праву скачать чужое аудио. +обрабатывает записи всех. Тот же шаг сузил правило просмотра коллекции `files` +владельцем: прежнее правило пускало всякого вошедшего, и знание идентификатора +файловой записи равнялось праву скачать чужое аудио. -**Учётная запись с задачами не удаляется.** Каскадное удаление у связи выключено, +**Учётная запись с записями не удаляется.** Каскадное удаление у связи выключено, но одного этого мало: при выключенном каскаде хранилище снимает ссылку и -сохраняет запись без проверок — задачи остались бы, но стали бы ничьими, а ничья -задача не достаётся никому. Отказ ставит слой приложения `GuardOwnerDeletion`, +сохраняет запись без проверок — записи остались бы, но стали бы ничьими, а ничья +запись не достаётся никому. Отказ ставит слой приложения `GuardOwnerDeletion`, а не правило коллекции: панель ходит правами суперпользователя, и правило её не -судит. Цена названа прямо — владелец панели упирается в отказ, а удаления -записей в сервисе пока нет вовсе. +судит. Считаются все коллекции с колонкой владельца — `audio_records`, `files` и +`topics`, — и перечень живёт одним местом: пропущенная коллекция пропускает +удаление вперёд, а наружу приезжает подсказка библиотеки про обязательную связь +вместо нашего отказа с причиной. -**Правила доступа задач пусты**, то есть перечислять и читать их может только -владелец панели. Проверено прогоном: анонимный запрос к -`/api/collections/*/records` отвечает `403`, к `/api/logs`, `/api/backups`, -`/api/settings` и `/api/crons` — `401`. +**Правила доступа новых коллекций пусты**, то есть перечислять и читать их может +только владелец панели. Содержимое записи отдаёт собственный адрес сервиса, а не +поверхность хранилища; непустое правило открыло бы перечисление коллекции впрок. +Проверено прогоном: анонимный запрос к `/api/collections/*/records` отвечает +`403`, к `/api/logs`, `/api/backups`, `/api/settings` и `/api/crons` — `401`. ## Представление данных Чем физически лежит запись и что происходит при чтении и записи. -- **Расшифровка лежит целиком в поле `transcription_text`** одной строкой. - Запись длиной в час даёт десятки килобайт в одной ячейке; читается она - целиком при каждом чтении задачи и при каждом захвате. +- **Расшифровка лежит отдельной строкой `texts`**, а не колонкой записи. Захват + её не тянет вовсе: он возвращает **идентификатор и признак своего захвата**, а + колонки шаг читает отдельным чтением. Прежде расшифровка стояла колонкой той + же строки и читалась при каждом опросе очереди. - **Аудио лежит в раскладке хранилища:** `data/storage/<коллекция>/<запись>/<имя>` рядом с файлом атрибутов. Имя задаёт сервис — `<расширение>`; собственного суффикса хранилище не дописывает, @@ -135,19 +241,28 @@ capability, и третий смысл развёл бы одно слово п Без этого сужения закрытие API обходится двумя запросами — завести себе запись и войти паролем. Продление сессии закрыто слоем в приложении, а не настройкой коллекции: библиотека выдаёт сессию продлеваемой всегда. -- **Захват задачи — один запрос с `RETURNING`**, мимо записей коллекции. +- **Захват записи — один запрос с `RETURNING`**, мимо записей коллекции. `app.DB()` направляет всё, кроме выборок, в пул с единственным соединением, поэтому захваты выстраиваются в очередь. Порядок выборки — по времени заведения **и по ключу**: время неуникально, и без ключа порядок обработки - невоспроизводим. + невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку протухания + захвата и отсутствию признака остановки; срок протухания выбирается по рубежу + самой записи прямо в запросе — воркер, ещё не знающий, что вытянет, подставить + его не может. - **Запись результата условна по признаку захвата** — инвариант «Результат пишет только держатель захвата» в [CLAUDE.md](../CLAUDE.md), «Инварианты» (major); норма — [pipeline](../openspec/specs/pipeline/spec.md). Здесь названо потому, что условие проверяется тем же запросом, что и сам захват. -- **Список колонок задан четырьмя местами** — `applyToRecord`, `recordToJob`, - константой `acquireColumns` и структурой `acquiredRow`, — плюс шагом схемы. - Все четыре лежат в одном пакете, но компилятор видит два: правило правки и его - серьёзность (critical/major) — инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты». +- **Список колонок задан двумя местами** — `applyOwnedByPipeline` вместе с + `applyToRecord` и `recordToAudioRecord`, — плюс шагом схемы. Мест было четыре, + пока захват перечислял колонки поимённо; теперь он возвращает идентификатор, и + перечень перестал расти с моделью. Правило правки и его серьёзность — + инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты»; сверку держат правила + `internal/archrules`. +- **Перечень рубежей объявлен одним дескриптором** — `internal/entity/stage.go`. + Из него выводятся выбор шага, отбор захвата, срок протухания и предел простоя: + рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон + по инварианту проекта не пишется в журнал и не считается в метрику. - **Отказ хранилища наружу не выходит дословно.** Он несёт ключ файла целиком, а ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают свой текст с идентификатором записи, а цепочку `%w` обрывают. То же у выгрузки @@ -157,14 +272,22 @@ capability, и третий смысл развёл бы одно слово п | Настройка | Значение | Где | Откуда число | | --- | --- | --- | --- | -| Предел попыток | 5 | `service/transcribe.go` | обычное умолчание, не замер | -| Пауза перед повтором | `2^(попытка−1)` с, потолок 5 минут | там же | то же | -| Срок захвата, конвертация | 8 часов | там же | потолок записи 6 часов плюс запас | -| Срок захвата, распознавание | 8 часов | там же | то же | -| Срок захвата, проверка операции | 1 час | там же | опрос идёт секунды | -| Задержка перед первой проверкой операции | 10 секунд | там же | как было | +| Предел отказов | 5 | `service/transcribe.go` | обычное умолчание, не замер | +| Пауза перед повтором | `2^(отказ−1)` с, потолок 5 минут | там же | то же | +| Срок захвата, приведение | 8 часов | `entity/stage.go` | потолок записи 6 часов плюс запас | +| Срок захвата, отправка на распознавание | 8 часов | там же | то же | +| Срок захвата, опрос операции | 1 час | там же | опрос идёт секунды | +| Срок захвата, завершение | 1 час | там же | запись текста и ответ идут секунды | +| Число воркеров конвейера | 3 | конфиг, `[pipeline] workers` | решение владельца; ноль — законное значение | +| Предел простоя, своя работа | 60 минут | конфиг, `[pipeline] own_work_limit_minutes` | решение владельца 2026-08-14: сторож ловит зависание, а не долгую работу. Число **меньше** времени приведения многочасовой записи, и цена названа прямо — остановка обратима. Предел этот работает только по записи, вернувшейся в выборку: см. строку ниже | +| Предел простоя, чужая операция | 1440 минут | конфиг, `[pipeline] foreign_work_limit_minutes` | сколько идёт распознавание долгой записи, никто не мерил: ошибаемся в сторону долгого | +| Версия вида структуры реплик | 1 | `entity.StructureVersion` | первая | +| Потолок тем на запись | 5 | `entity.MaxTopicsPerRecord` | решение владельца: без него часовой разговор даёт два десятка тем | +| Потолок сохранённого ответа провайдера | 256 МиБ | шаг `202608140002` | ответ многословнее расшифровки: несёт альтернативы, время каждого слова и разбор говорящих | +| Потолок структуры реплик | 16 МиБ | там же | шестичасовой разговор даёт порядка мегабайта текста с временем | +| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было | | Задержка между проверками операции | 5 секунд | там же | как было | -| Пауза воркера между попытками | 1 секунда | `controller/worker/worker.go` | как было | +| Пауза воркера между прогонами | 1 секунда | `controller/worker/worker.go` | как было | | Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` | предел Telegram | | Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — | | Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — | @@ -177,6 +300,15 @@ capability, и третий смысл развёл бы одно слово п | Потолок времени на вход у провайдера | 10 минут | `controller/http/auth.go` | дольше носитель состояния не нужен | | Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым | +**У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел +простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок +протухания ещё не истёк. Значит для держателя, погибшего жёстко — контейнер убит +по нехватке памяти или `docker kill`, — запись невидима сторожу до истечения +**срока захвата** её рубежа, то есть восьми часов у приведения и отправки. +Замерено прогоном: до истечения срока повторный захват записи не выдаёт, и +остановка «застряла» наступает только после него. Мягкая остановка сюда не +подпадает: она снимает захват сама. + **Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело diff --git a/docs/review.md b/docs/review.md index 98733c6..a8cf35c 100644 --- a/docs/review.md +++ b/docs/review.md @@ -69,8 +69,8 @@ **Репозиторий хранилища** (`internal/adapter/repo/pocketbase`; шаги схемы — подпакетом `migrations`): -- список колонок совпадает во всех четырёх местах — `applyToRecord`, - `recordToJob`, `acquireColumns`, `acquiredRow` — и в шаге схемы (инвариант +- список колонок совпадает в обоих местах — `applyOwnedByPipeline` вместе с + `applyToRecord` и `recordToAudioRecord` — и в шаге схемы (инвариант [CLAUDE.md](../CLAUDE.md), «Инварианты»); - захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только держатель захвата; @@ -109,11 +109,15 @@ приведение типа на этом месте — настоящий дефект, закрытый 2026-08-11 задачей `errors-as-instead-of-typecast`. Появилось снова — это регрессия, и выбрасывать её как известную нельзя. -- **«Захват задачи не в транзакции — гонка двух воркеров».** По построению её - нет: три воркера читают три разных состояния, и одну строку они не делят. - Механика захвата и её слабые места — [database.md](database.md), - «Представление данных». Находка становится настоящей ровно тогда, когда - появится второй экземпляр процесса или второй воркер на то же состояние. +- **«Захват записи не в транзакции — гонка двух воркеров».** ~~По построению её + нет: три воркера читают три разных состояния, и одну строку они не делят.~~ + **Отменено 2026-08-14 задачей `record-centric-model`:** построение снято. Пул + одинаковых воркеров конкурирует за один и тот же набор записей, и второй + воркер на тот же рубеж теперь есть всегда, когда их больше одного. Находка о + гонке захвата стала настоящей и выбрасывается только по существу — механика + захвата и её слабые места в [database.md](database.md), «Представление + данных». Строка оставлена отменённой, а не удалена: прогон, помнящий прежнюю + редакцию, иначе выбросил бы настоящую находку как известную. - **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в [database.md](database.md); срок хранения не задан сознательно, задачи на него нет. Новой находкой это не считается, пока не измерен рост. @@ -165,7 +169,8 @@ первые две описаны частично. Поведение прочих узлов, включая приём из Telegram, живёт в обзоре под маркерами долга, а соблазн дописать туда ещё — самый большой. -- `conventions`: новая колонка правится во всех четырёх местах репозитория +- `conventions`: новая колонка правится в обоих местах репозитория, а новый + рубеж — одним дескриптором (CLAUDE.md, «Инварианты»). - `autotests`: покрыт ли изменённый шаг конвейера хоть одним **проходящим** тестом. Что уже закрыто проверками, видно по журналу дефектов ниже и по diff --git a/docs/security.md b/docs/security.md index 7bff1c5..36ebcb4 100644 --- a/docs/security.md +++ b/docs/security.md @@ -112,7 +112,17 @@ Telegram отправителю. списком; `filepath.Ext` режет по последней точке и не пропускает разделитель каталогов, но это единственное, что стоит между входом и именем файла. - **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с - расширением. Бакет один на все записи, префикса по пользователю нет. + расширением. Бакет один на все записи, префикса по пользователю нет. С + 2026-08-14 копия там файлом записи не считается: она существует лишь потому, + что провайдер читает аудио по адресу, и её ключ живёт в строке попытки + распознавания. +- **Вторая раскладка файла на диске** появилась 2026-08-14 вместе с сохранённым + ответом провайдера: `data/storage//<попытка>/<имя>.payload`. Имя + задаёт сервис, как и у аудио. Содержимое там — **полный текст речи**, а не + метаданные, поэтому поле помечено защищённым, правило просмотра коллекции + оставлено пустым, и ссылка на вложение подпадает под тот же запрет, что и + ссылка на аудио: в журнал она не пишется. Проверено прогоном: без сессии, с + чужим и со своим токеном файла ссылка отвечает «не найдено». - **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла помечено защищённым задачей `oidc-login` 2026-08-12: пройти по ссылке теперь можно только с коротким токеном файла, который выдаётся по сессии, и запрос @@ -121,12 +131,14 @@ Telegram отправителю. остаётся: **имя файла в хранилище в журнал не пишется** — иначе строка журнала вместе с идентификатором записи собирала бы ссылку целиком и работала бы бессрочно. В журнал идёт расширение своим полем. -- **Идентификатор задачи** — 15 знаков, выдаёт хранилище. Он же единственное, +- **Идентификатор записи** — 15 знаков, выдаёт хранилище. Он же единственное, что защищает `GET /api/status/:id`. - **Поверхность самого хранилища.** Вместе с переводом наружу выходят `/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`, `/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то - есть доступны они только владельцу панели; коды, снятые прогоном, — + есть доступны они только владельцу панели, — и коллекции, заведённые + 2026-08-14, тоже: содержимое записи отдаёт собственный адрес сервиса, а не + поверхность хранилища. Коды, снятые прогоном, — [database.md](database.md), «Коллекции», норма — [storage](../openspec/specs/storage/spec.md), «Наружу хранилище отдаёт только то, что заказано». @@ -210,7 +222,13 @@ Telegram отправителю. ## Что чувствительнее чего 1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка; - это самое чувствительное, что здесь есть. + это самое чувствительное, что здесь есть. С 2026-08-14 оно живёт не одной + колонкой, а шестью коллекциями: сама запись (заголовок и краткое описание), + `texts` (расшифровка и вычитанный текст), `structures` (реплики со временем), + `recognitions` (**сырой ответ провайдера вложением — полный текст речи**), + `record_events` (журнал событий, содержимого не несёт) и `topics` (словарь + тем человека). Всякая новая коллекция, куда содержимое переезжает, закрывается + наравне с записью — норму держит спека `storage`. 2. **Токен бота Telegram.** Даёт полный доступ к боту и к перепискам с ним. 3. **Ключи Yandex Cloud** — `speech_kit_api_key` и пара ключей Object Storage. Утечка оплачивается деньгами и доступом к бакету. @@ -336,6 +354,17 @@ Telegram отправителю. вовсе. С 2026-08-11 это уже не недосмотр, а следствие решения хранить бессрочно, и тем же днём заведена задача `delete-record`: своя запись убирается вместе с файлом, объектом в Object Storage и всеми уровнями текста. - Пока она не сделана, единственный способ убрать запись — руками в базе и в - каталоге на сервере. Учёт расхода удалению не подлежит по решению человека: - деньги потрачены, а строки потребления текста не содержат. + Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а + строки потребления текста не содержат. + + **Руками запись сегодня не удаляется, и прежняя строка об этом была неверна.** + Проверено прогоном 2026-08-14: содержимое живёт в коллекциях, перечисленных + выше («Что чувствительнее чего»), связи приложений с записью обязательны и + каскада не имеют, поэтому удаление самой + строки записи отвергается хранилищем, а удаление её файлов проходит молча. + Владелец, выполнивший прежнюю процедуру, стирает аудио и **оставляет полный + текст речи** — расшифровку, разбивку по репликам и сырой ответ провайдера + файлом на диске. Порядок, которым запись убирается на самом деле: сперва + строки приложений — журнал событий, попытка распознавания вместе с её + вложением, структура, тексты, — потом сама запись, потом её файлы. До + `delete-record` это единственный способ, и он ручной целиком. diff --git a/go.mod b/go.mod index 7679328..2684d17 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/stretchr/testify v1.10.0 github.com/yandex-cloud/go-genproto v0.17.0 google.golang.org/grpc v1.82.1 + google.golang.org/protobuf v1.36.11 ) require ( @@ -73,7 +74,6 @@ require ( golang.org/x/text v0.40.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect - google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.74.1 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/internal/adapter/recognizer/memory.go b/internal/adapter/recognizer/memory.go index 7f20f84..7c56def 100644 --- a/internal/adapter/recognizer/memory.go +++ b/internal/adapter/recognizer/memory.go @@ -2,22 +2,79 @@ package recognizer import ( "context" + "encoding/json" + "errors" "io" - "git.vakhrushev.me/av/transcriber/internal/entity" "github.com/google/uuid" + + "git.vakhrushev.me/av/transcriber/internal/entity" ) +// MemoryAudioRecognizer — подставной распознаватель для местного запуска и +// проверок. Прогон на реальных ключах ради проверки кода запрещён: распознавание +// и хранение в Object Storage оплачиваются по факту. +// +// Сырой ответ он отдаёт своего вида, но настоящего: тем же путём, что и живой +// адаптер, — сохранённые байты разбираются обратно в реплики, и структура +// строится без единого обращения наружу. type MemoryAudioRecognizer struct{} -func (r *MemoryAudioRecognizer) Recognize(ctx context.Context, file io.Reader, fileName string) (operationID string, err error) { +const memoryProvider = "memory" + +func (r *MemoryAudioRecognizer) Provider() string { return memoryProvider } + +func (r *MemoryAudioRecognizer) Model() string { return "memory" } + +func (r *MemoryAudioRecognizer) Upload(ctx context.Context, file io.Reader, objectKey string) (string, error) { + return "memory://" + objectKey, nil +} + +func (r *MemoryAudioRecognizer) ObjectExists(ctx context.Context, objectKey string, size int64) (bool, error) { + return false, nil +} + +func (r *MemoryAudioRecognizer) Submit(ctx context.Context, sourceURI string) (string, error) { return uuid.NewString(), nil } -func (r *MemoryAudioRecognizer) GetRecognitionText(ctx context.Context, operationID string) (string, error) { - return "Foo bar, Baz.", nil -} - -func (r *MemoryAudioRecognizer) CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) { +func (r *MemoryAudioRecognizer) CheckStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) { return entity.NewCompletedResult(), nil } + +func (r *MemoryAudioRecognizer) Fetch(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error) { + replicas := []entity.Replica{ + {StartMs: 0, EndMs: 1000, Text: "Foo bar,"}, + {StartMs: 1000, EndMs: 2000, Text: "Baz."}, + } + raw, err := json.Marshal(replicas) + if err != nil { + return nil, errors.New("failed to encode memory payload") + } + return &entity.RecognitionOutcome{ + Replicas: replicas, + PlainText: "Foo bar, Baz.", + Raw: raw, + }, nil +} + +func (r *MemoryAudioRecognizer) Parse(raw []byte) (*entity.RecognitionOutcome, error) { + var replicas []entity.Replica + if err := json.Unmarshal(raw, &replicas); err != nil { + return nil, errors.New("failed to decode memory payload") + } + + var plain []byte + for _, replica := range replicas { + if len(plain) > 0 { + plain = append(plain, ' ') + } + plain = append(plain, replica.Text...) + } + + return &entity.RecognitionOutcome{ + Replicas: replicas, + PlainText: string(plain), + Raw: raw, + }, nil +} diff --git a/internal/adapter/recognizer/yandex/payload_test.go b/internal/adapter/recognizer/yandex/payload_test.go new file mode 100644 index 0000000..4ffe4e5 --- /dev/null +++ b/internal/adapter/recognizer/yandex/payload_test.go @@ -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} +} diff --git a/internal/adapter/recognizer/yandex/recognizer.go b/internal/adapter/recognizer/yandex/recognizer.go index 8db2113..4ba3785 100644 --- a/internal/adapter/recognizer/yandex/recognizer.go +++ b/internal/adapter/recognizer/yandex/recognizer.go @@ -9,6 +9,10 @@ import ( "git.vakhrushev.me/av/transcriber/internal/entity" ) +// ProviderName — имя провайдера, под которым сохраняется попытка распознавания. +// По нему видно, чем считана запись, когда провайдеров станет больше одного. +const ProviderName = "yandex-speechkit" + type YandexAudioRecognizerConfig struct { // s3 Region string @@ -56,36 +60,44 @@ func (s *YandexAudioRecognizerService) Close() error { return s.sttService.Close() } +func (s *YandexAudioRecognizerService) Provider() string { return ProviderName } + +func (s *YandexAudioRecognizerService) Model() string { return RecognitionModel } + // startRecognitionTimeout — сколько ждём принятия операции, когда нас уже // остановили. Число меньше жёсткого предела остановки: иначе процесс убьют // прежде, чем ответ дойдёт, и защита ничего не даст. const startRecognitionTimeout = 10 * time.Second -func (s *YandexAudioRecognizerService) Recognize(ctx context.Context, file io.Reader, fileName string) (string, error) { - - // Заливка отменяется штатно: она дорога по времени, а повтор её бесплатен — - // объект ложится под тем же ключом. - err := s.s3Sevice.uploadFile(ctx, file, fileName) - if err != nil { +// Upload кладёт аудио туда, откуда провайдер его прочитает. +// +// Отменяется штатно: заливка дорога по времени, а повтор её бесплатен — объект +// ложится под тем же ключом. +func (s *YandexAudioRecognizerService) Upload(ctx context.Context, file io.Reader, objectKey string) (string, error) { + if err := s.s3Sevice.uploadFile(ctx, file, objectKey); err != nil { return "", err } + return s.s3Sevice.fileUrl(objectKey), nil +} - uri := s.s3Sevice.fileUrl(fileName) +// ObjectExists отвечает, лежит ли объект нужного размера. +// +// Сверка идёт по присутствию и длине, а не по отпечатку содержимого: признак +// целостности у составного объекта не равен отпечатку, и сверка хешем дала бы +// расхождение на всякой большой записи. +func (s *YandexAudioRecognizerService) ObjectExists(ctx context.Context, objectKey string, size int64) (bool, error) { + return s.s3Sevice.objectExists(ctx, objectKey, size) +} - // А вот принятие операции от отмены защищено. Окно короткое и дорогое: - // SpeechKit может операцию принять и начать считать деньги, а ответ до нас - // не доедет — идентификатор потеряется навсегда, и повтор оплатит ту же - // запись второй раз. Свой предел вызову оставлен, чтобы остановка не ждала - // вечно. +// Submit заводит операцию распознавания. Оплачивается наружу, поэтому от отмены +// защищён: окно короткое и дорогое — SpeechKit может операцию принять и начать +// считать деньги, а ответ до нас не доедет, и повтор оплатит ту же запись второй +// раз. Свой предел вызову оставлен, чтобы остановка не ждала вечно. +func (s *YandexAudioRecognizerService) Submit(ctx context.Context, sourceURI string) (string, error) { startCtx, cancel := protectFromCancel(ctx, startRecognitionTimeout) defer cancel() - opId, err := s.sttService.recognizeFileFromS3(startCtx, uri) - if err != nil { - return "", err - } - - return opId, nil + return s.sttService.recognizeFileFromS3(startCtx, sourceURI) } // protectFromCancel отвязывает вызов от отмены родителя, оставляя ему значения @@ -95,11 +107,26 @@ func protectFromCancel(ctx context.Context, timeout time.Duration) (context.Cont return context.WithTimeout(context.WithoutCancel(ctx), timeout) } -func (s *YandexAudioRecognizerService) GetRecognitionText(ctx context.Context, operationID string) (string, error) { - return s.sttService.getRecognitionText(ctx, operationID) +// Fetch забирает готовый результат и отдаёт его доменным: реплики со временем, +// плоский текст и байты ответа на хранение. Формата провайдера наружу не выходит +// ничего — ни один шаг конвейера не знает, каким потоком тот отвечает. +func (s *YandexAudioRecognizerService) Fetch(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error) { + return s.sttService.fetchRecognition(ctx, operationID) } -func (s *YandexAudioRecognizerService) CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) { +// Parse строит доменный результат из **сохранённого** ответа, не обращаясь к +// провайдеру. По нему архив пересчитывается без единого рубля. +func (s *YandexAudioRecognizerService) Parse(raw []byte) (*entity.RecognitionOutcome, error) { + responses, err := decodeResponses(raw) + if err != nil { + return nil, err + } + outcome := outcomeFromResponses(responses) + outcome.Raw = raw + return outcome, nil +} + +func (s *YandexAudioRecognizerService) CheckStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) { operation, err := s.sttService.checkOperationStatus(ctx, operationID) if err != nil { return nil, err diff --git a/internal/adapter/recognizer/yandex/s3.go b/internal/adapter/recognizer/yandex/s3.go index 96a2940..7f315cf 100644 --- a/internal/adapter/recognizer/yandex/s3.go +++ b/internal/adapter/recognizer/yandex/s3.go @@ -12,6 +12,7 @@ import ( "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/feature/s3/manager" "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/aws/smithy-go" ) @@ -89,6 +90,37 @@ func (s *yandexS3Service) uploadFile(ctx context.Context, file io.Reader, fileNa return nil } +// objectExists отвечает, лежит ли объект нужного размера. +// +// По нему шаг решает, повторять ли заливку: повтор её бесплатен, но дорог по +// времени на многочасовой записи. Сверка идёт по присутствию и длине, а не по +// отпечатку содержимого: признак целостности у составного объекта не равен +// отпечатку, и сверка хешем расходилась бы на всякой большой записи. +func (s *yandexS3Service) objectExists(ctx context.Context, objectKey string, size int64) (bool, error) { + out, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: aws.String(s.bucketName), + Key: aws.String(objectKey), + }) + if err != nil { + var notFound *s3types.NotFound + if errors.As(err, ¬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 { endpoint := strings.TrimRight(s.endpoint, "/") return fmt.Sprintf("%s/%s/%s", endpoint, s.bucketName, fileName) diff --git a/internal/adapter/recognizer/yandex/speechkit.go b/internal/adapter/recognizer/yandex/speechkit.go index f9f2b9d..38dc523 100644 --- a/internal/adapter/recognizer/yandex/speechkit.go +++ b/internal/adapter/recognizer/yandex/speechkit.go @@ -2,17 +2,20 @@ package yandex import ( "context" + "encoding/binary" "errors" "fmt" "io" - "strings" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/proto" stt "github.com/yandex-cloud/go-genproto/yandex/cloud/ai/stt/v3" "github.com/yandex-cloud/go-genproto/yandex/cloud/operation" + + "git.vakhrushev.me/av/transcriber/internal/entity" ) const ( @@ -134,9 +137,13 @@ func (s *speechKitService) recognizeFileFromS3(ctx context.Context, s3URI string return op.Id, nil } -// GetRecognitionResult получает результат распознавания по ID операции -func (s *speechKitService) getRecognitionText(ctx context.Context, operationID string) (string, error) { - // Добавляем авторизацию и folder_id в контекст +// fetchRecognition забирает результат операции целиком и отдаёт его доменным, +// вместе с сырым ответом на хранение. +// +// Ответ сохраняется потому, что **результат операции у провайдера не +// переспрашивается**: связь реплики с говорящим сервис строить пока не умеет, и +// когда научится, архив пересчитается из сохранённого без единого рубля. +func (s *speechKitService) fetchRecognition(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error) { ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey) ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID) @@ -146,11 +153,10 @@ func (s *speechKitService) getRecognitionText(ctx context.Context, operationID s stream, err := s.sttClient.GetRecognition(ctx, req) if err != nil { - return "", fmt.Errorf("failed to get recognition stream: %w", err) + return nil, fmt.Errorf("failed to get recognition stream: %w", err) } - var sb strings.Builder - + var responses []*stt.StreamingResponse for { resp, err := stream.Recv() if err != nil { @@ -160,19 +166,19 @@ func (s *speechKitService) getRecognitionText(ctx context.Context, operationID s if errors.Is(err, io.EOF) { break } - return "", fmt.Errorf("failed to receive recognition response: %w", err) - } - if refinement := resp.GetFinalRefinement(); refinement != nil { - if text := refinement.GetNormalizedText(); text != nil { - for _, alt := range text.Alternatives { - sb.WriteString(alt.Text) - sb.WriteString(" ") - } - } + return nil, fmt.Errorf("failed to receive recognition response: %w", err) } + responses = append(responses, resp) } - return sb.String(), nil + raw, err := encodeResponses(responses) + if err != nil { + return nil, err + } + + outcome := outcomeFromResponses(responses) + outcome.Raw = raw + return outcome, nil } // checkOperationStatus проверяет статус операции распознавания @@ -190,3 +196,92 @@ func (s *speechKitService) checkOperationStatus(ctx context.Context, operationID return op, nil } + +// encodeResponses укладывает ответ провайдера целиком, в том виде, в каком он +// пришёл: сообщения потока подряд, каждое со своей длиной впереди. +// +// Форма **двоичная**, а не текстовая, и это несущее решение. Текстовое +// представление собирается по нашей скомпилированной схеме и молча выбрасывает +// поля, которых в ней нет, — а провайдер добавляет их без предупреждения. +// Двоичная форма неизвестные поля переносит: они переживают запись и чтение и +// станут читаемыми, когда мы обновим схему. Ради этого архив и заводился — +// результат операции у провайдера не переспрашивается, и повторное +// распознавание стоит денег. +// +// Цена названа прямо: сохранённое не читается глазами и не разбирается ничем, +// кроме нашего же кода. +func encodeResponses(responses []*stt.StreamingResponse) ([]byte, error) { + var raw []byte + for _, resp := range responses { + encoded, err := proto.Marshal(resp) + if err != nil { + // Текст расшифровки наружу не выходит даже отказом: сообщение + // провайдера несёт её целиком. + return nil, errors.New("failed to encode provider response") + } + raw = binary.AppendUvarint(raw, uint64(len(encoded))) + raw = append(raw, encoded...) + } + return raw, nil +} + +// decodeResponses читает сохранённый ответ провайдера обратно. +func decodeResponses(raw []byte) ([]*stt.StreamingResponse, error) { + var responses []*stt.StreamingResponse + + for len(raw) > 0 { + size, read := binary.Uvarint(raw) + if read <= 0 || uint64(len(raw)-read) < size { + return nil, errors.New("stored provider payload is truncated") + } + raw = raw[read:] + + var resp stt.StreamingResponse + if err := proto.Unmarshal(raw[:size], &resp); err != nil { + return nil, errors.New("failed to decode provider response") + } + responses = append(responses, &resp) + raw = raw[size:] + } + + return responses, nil +} + +// outcomeFromResponses строит доменный результат: реплики со временем и плоский +// текст. Формата провайдера отсюда наружу не выходит ничего. +// +// Говорящие не размечаются: связь реплики с разбором говорящего у провайдера не +// выяснена. Структура при этом строится из сохранённого ответа, поэтому разметка +// станет возможной без повторной оплаты. +func outcomeFromResponses(responses []*stt.StreamingResponse) *entity.RecognitionOutcome { + outcome := &entity.RecognitionOutcome{} + + var plain []byte + for _, resp := range responses { + refinement := resp.GetFinalRefinement() + if refinement == nil { + continue + } + text := refinement.GetNormalizedText() + if text == nil { + continue + } + for _, alt := range text.GetAlternatives() { + if alt.GetText() == "" { + continue + } + outcome.Replicas = append(outcome.Replicas, entity.Replica{ + StartMs: alt.GetStartTimeMs(), + EndMs: alt.GetEndTimeMs(), + Text: alt.GetText(), + }) + if len(plain) > 0 { + plain = append(plain, ' ') + } + plain = append(plain, alt.GetText()...) + } + } + + outcome.PlainText = string(plain) + return outcome +} diff --git a/internal/adapter/repo/pocketbase/file_repo.go b/internal/adapter/repo/pocketbase/file_repo.go index 09d5f97..d9353f7 100644 --- a/internal/adapter/repo/pocketbase/file_repo.go +++ b/internal/adapter/repo/pocketbase/file_repo.go @@ -112,11 +112,15 @@ func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) { return work, nil } -// CreateLocal кладёт рабочую копию в хранилище. Имя задаём мы: умолчание -// библиотеки строит его из имени, данного отправителем, а имя отправителя в -// хранилище не попадает — путь к файлу читается в журнале, и инвариант -// приватности этого не допускает. Свой суффикс хранилище допишет само. -func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile, ownerID string) (*entity.File, error) { +// Create кладёт рабочую копию в хранилище. Имя задаём мы: умолчание библиотеки +// строит его из имени, данного отправителем, а имя отправителя в хранилище не +// попадает — путь к файлу читается в журнале, и инвариант приватности этого не +// допускает. Свой суффикс хранилище допишет само. +// +// Копий у записи ровно две — принятая и приведённая, — и обе местные. Прежний +// путь заведения записи о копии во внешнем хранилище отсюда ушёл: та копия +// файлом записи не считается, а её ключ живёт в строке попытки распознавания. +func (repo *FileRepository) Create(name string, work contract.WorkFile, meta contract.FileMeta, ownerID string) (*entity.File, error) { collection, err := findCollection(repo.app, migrations.FilesCollection) if err != nil { return nil, err @@ -132,6 +136,8 @@ func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile, own record.Set("file", stored) record.Set("location", entity.LocationLocal) record.Set("size", stored.Size) + record.Set("format", meta.Format) + record.Set("duration_ms", meta.DurationMs) // Владелец файла — владелец записи, которой файл принадлежит. Пустой значит // «файл без владельца»: таков всякий файл записи, принятой ботом. Правило // просмотра коллекции сужено этой колонкой, и без неё чужое аудио осталось @@ -148,25 +154,6 @@ func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile, own return recordToFile(record), nil } -func (repo *FileRepository) CreateRemote(objectKey string, size int64, ownerID string) (*entity.File, error) { - collection, err := findCollection(repo.app, migrations.FilesCollection) - if err != nil { - return nil, err - } - - record := core.NewRecord(collection) - record.Set("location", entity.LocationS3) - record.Set("object_key", objectKey) - record.Set("size", size) - record.Set("owner", ownerID) - - if err := repo.app.Save(record); err != nil { - return nil, fmt.Errorf("failed to store remote file record: %w", err) - } - - return recordToFile(record), nil -} - func (repo *FileRepository) GetByID(id string) (*entity.File, error) { record, err := repo.app.FindRecordById(migrations.FilesCollection, id) if err != nil { @@ -262,16 +249,13 @@ func firstFileName(record *core.Record) string { } func recordToFile(record *core.Record) *entity.File { - name := firstFileName(record) - if name == "" { - name = record.GetString("object_key") - } - return &entity.File{ - Id: record.Id, - Location: record.GetString("location"), - FileName: name, - Size: int64(record.GetInt("size")), - CreatedAt: record.GetDateTime("created").Time(), + Id: record.Id, + Location: record.GetString("location"), + FileName: firstFileName(record), + Size: int64(record.GetInt("size")), + Format: record.GetString("format"), + DurationMs: int64(record.GetInt("duration_ms")), + CreatedAt: record.GetDateTime("created").Time(), } } diff --git a/internal/adapter/repo/pocketbase/file_repo_test.go b/internal/adapter/repo/pocketbase/file_repo_test.go deleted file mode 100644 index ff82c70..0000000 --- a/internal/adapter/repo/pocketbase/file_repo_test.go +++ /dev/null @@ -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, "объявленный потолок выше проверяемого размера") -} diff --git a/internal/adapter/repo/pocketbase/job_mapping.go b/internal/adapter/repo/pocketbase/job_mapping.go deleted file mode 100644 index eb3e6ab..0000000 --- a/internal/adapter/repo/pocketbase/job_mapping.go +++ /dev/null @@ -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 -} diff --git a/internal/adapter/repo/pocketbase/migrations/202608140002_record_centric_model.go b/internal/adapter/repo/pocketbase/migrations/202608140002_record_centric_model.go new file mode 100644 index 0000000..3e0b868 --- /dev/null +++ b/internal/adapter/repo/pocketbase/migrations/202608140002_record_centric_model.go @@ -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 +} diff --git a/internal/adapter/repo/pocketbase/migrations/migrations.go b/internal/adapter/repo/pocketbase/migrations/migrations.go index 20c1596..5462e70 100644 --- a/internal/adapter/repo/pocketbase/migrations/migrations.go +++ b/internal/adapter/repo/pocketbase/migrations/migrations.go @@ -23,7 +23,19 @@ import ( // меняются только новым шагом схемы. const ( FilesCollection = "files" - JobsCollection = "transcribe_jobs" + // JobsCollection — прежняя коллекция задач. Шаг 202608140002 её удаляет; + // имя остаётся здесь, потому что на него ссылаются прежние шаги схемы, а + // применённый шаг не переписывается. + JobsCollection = "transcribe_jobs" + // RecordsCollection — аудиозапись, центральная сущность сервиса. Имя в + // snake_case, как у соседей по схеме: одно исключение разошлось бы молча по + // константе имён, запросу захвата, правилам панели и запрету удаления. + RecordsCollection = "audio_records" + TextsCollection = "texts" + StructuresCollection = "structures" + RecognitionsCollection = "recognitions" + RecordEventsCollection = "record_events" + TopicsCollection = "topics" // UsersCollection заводит не наш шаг, а системный шаг библиотеки. Имя стоит // здесь потому, что на него ссылаются и шаги схемы, и проверка предъявителя // на приёме: строковый литерал в двух местах разошёлся бы молча. @@ -36,6 +48,7 @@ func init() { pbmigrations.Register(up202608110001, down202608110001, "202608110001_init.go") pbmigrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go") pbmigrations.Register(up202608140001, down202608140001, "202608140001_record_owner.go") + pbmigrations.Register(up202608140002, down202608140002, "202608140002_record_centric_model.go") } func ptr[T any](v T) *T { return &v } diff --git a/internal/adapter/repo/pocketbase/owner_guard.go b/internal/adapter/repo/pocketbase/owner_guard.go index 31b34b5..0244445 100644 --- a/internal/adapter/repo/pocketbase/owner_guard.go +++ b/internal/adapter/repo/pocketbase/owner_guard.go @@ -11,7 +11,7 @@ import ( ) // GuardOwnerDeletion отвергает удаление учётной записи, у которой остались -// задачи расшифровки. +// аудиозаписи, их файлы либо темы её словаря. // // Колонка владельца — связь с выключенным каскадным удалением, и одного этого // мало: при выключенном каскаде хранилище не удаляет ссылающуюся запись, а @@ -29,10 +29,11 @@ import ( // только панельное — умолчание библиотеки разрешает вошедшему удалить свою // учётную запись запросом, так что страж закрывает и публичную поверхность. // -// Считаются **обе** коллекции с владельцем. Файл переживает свою задачу: шаг -// конвейера заводит его до сохранения задачи, и потерянный захват оставляет файл -// с владельцем и без ссылки. Учётная запись, у которой остались одни такие -// файлы, без этого счёта удалялась бы штатно, а аудио становилось бы ничьим. +// Считаются **все** коллекции с владельцем, и перечень их живёт одним списком +// ниже. Файл переживает свою запись: шаг конвейера заводит его до сохранения, и +// потерянный захват оставляет файл с владельцем и без ссылки. Учётная запись, у +// которой остались одни такие файлы, без этого счёта удалялась бы штатно, а +// аудио становилось бы ничьим. func GuardOwnerDeletion(app core.App) { app.OnRecordDelete(migrations.UsersCollection).BindFunc(func(e *core.RecordEvent) error { count, err := countOwned(e.App, e.Record.Id) @@ -62,13 +63,25 @@ func GuardOwnerDeletion(app core.App) { }) } -// countOwned считает всё, что принадлежит учётной записи, — по обеим коллекциям -// с колонкой владельца. Перечень живёт здесь одним списком: разойдясь с шагом -// схемы, он оставил бы половину архива без защиты молча. +// ownedCollections — коллекции с колонкой владельца. Перечень живёт здесь одним +// списком, и разойтись с шагом схемы ему нельзя: пропущенная коллекция +// пропускает удаление вперёд, а наружу приезжает не наш отказ с причиной, а +// подсказка библиотеки про обязательную связь — та самая, по которой владелец +// панели пойдёт удалять записи руками. +// +// Так уже случилось однажды: `topics` завелась третьей и в списке не появилась. +var ownedCollections = []string{ + migrations.RecordsCollection, + migrations.FilesCollection, + migrations.TopicsCollection, +} + +// countOwned считает всё, что принадлежит учётной записи, — по всем коллекциям +// с колонкой владельца. func countOwned(app core.App, ownerID string) (int64, error) { var total int64 - for _, collection := range []string{migrations.JobsCollection, migrations.FilesCollection} { + for _, collection := range ownedCollections { count, err := app.CountRecords(collection, dbx.HashExp{"owner": ownerID}) if err != nil { return 0, fmt.Errorf("failed to count owned records in %s: %w", collection, err) diff --git a/internal/adapter/repo/pocketbase/owner_test.go b/internal/adapter/repo/pocketbase/owner_test.go index 31c1ab6..5c809e0 100644 --- a/internal/adapter/repo/pocketbase/owner_test.go +++ b/internal/adapter/repo/pocketbase/owner_test.go @@ -4,179 +4,134 @@ import ( "strings" "testing" + "github.com/google/uuid" "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/tools/router" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" + "git.vakhrushev.me/av/transcriber/internal/clock" "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" ) -// newAccount заводит учётную запись и отдаёт её идентификатор. -func newAccount(t *testing.T, app core.App, email string) string { +// Страж удаления учётной записи — единственное, что стоит между владельцем +// панели и молчаливым обезличиванием чужого архива: при выключенном каскаде +// хранилище снимает ссылку и сохраняет запись без проверок. + +func newAccount(t *testing.T, app core.App) *core.Record { t.Helper() users, err := app.FindCollectionByNameOrId(migrations.UsersCollection) require.NoError(t, err) record := core.NewRecord(users) - record.Set("email", email) + record.Set("email", uuid.NewString()+"@example.test") record.Set("verified", true) - record.SetRandomPassword() + record.Set("password", uuid.NewString()) require.NoError(t, app.Save(record)) - return record.Id + return record } -// Колонка владельца заводится шагом схемы на чистой базе, и умолчания у неё нет. -func TestOwnerColumnHasNoDefault(t *testing.T) { - app := newTestApp(t) +// newRecordOf заводит аудиозапись названного владельца. +func newRecordOf(t *testing.T, app core.App, ownerID string) *entity.AudioRecord { + t.Helper() - for _, name := range []string{migrations.JobsCollection, migrations.FilesCollection} { - collection, err := app.FindCollectionByNameOrId(name) - require.NoError(t, err) - - field := collection.Fields.GetByName("owner") - require.NotNil(t, field, "колонка владельца заведена в %s", name) - - relation, ok := field.(*core.RelationField) - require.True(t, ok, "владелец — связь с учётной записью, а не строка") - assert.False(t, relation.Required, "пустое значение допустимо ради записей бота") - assert.False(t, relation.CascadeDelete, "удаление учётной записи не уносит записи") - - // Умолчания у связи нет по устройству: запись, чей владелец не назван, - // не достаётся никому по недосмотру схемы. - record := core.NewRecord(collection) - assert.Empty(t, record.GetString("owner"), "новая запись приходит без владельца") + record := &entity.AudioRecord{ + State: entity.StateUploaded, + StateEnteredAt: clock.Now(), + Source: entity.SourceApi, } -} - -// Чужая задача, ничья и несуществующая дают одну и ту же ошибку. -func TestGetByID_NarrowedByOwner(t *testing.T) { - app := newTestApp(t) - repo := NewTranscriptJobRepository(app) - - mine := newAccount(t, app, "mine@example.com") - stranger := newAccount(t, app, "stranger@example.com") - - owned := newJob(t, repo, entity.StateCreated) - owned.OwnerID = &mine - require.NoError(t, repo.Save(owned, "")) - // Владельца кладёт заведение, а не сохранение конвейера, — ставим его прямо. - record, err := app.FindRecordById(migrations.JobsCollection, owned.Id) - require.NoError(t, err) - record.Set("owner", mine) - require.NoError(t, app.Save(record)) - - ownerless := newJob(t, repo, entity.StateCreated) - - got, err := repo.GetByID(owned.Id, mine) - require.NoError(t, err, "своя задача отдаётся") - assert.Equal(t, owned.Id, got.Id) - - _, foreignErr := repo.GetByID(owned.Id, stranger) - _, ownerlessErr := repo.GetByID(ownerless.Id, mine) - _, missingErr := repo.GetByID("nonexistent0000", mine) - - var notFound *contract.JobNotFoundError - require.ErrorAs(t, foreignErr, ¬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) + if ownerID != "" { + record.OwnerID = &ownerID } + require.NoError(t, NewAudioRecordRepository(app).Create(record)) + return record } -// Удаление учётной записи с задачами отвергается: связь с выключенным каскадом -// иначе снимает ссылку, и архив человека становится ничьим и недостижимым. +// Учётная запись с архивом не удаляется, и отказ называет причину — иначе +// наружу приезжает подсказка библиотеки про обязательную связь, по которой +// владелец панели пойдёт удалять записи руками. func TestGuardOwnerDeletion(t *testing.T) { - // Страж вешает сама сборка хранилища — звать его отдельно не нужно и нельзя: - // второй вызов повесил бы второй слой. - app := newTestApp(t) - repo := NewTranscriptJobRepository(app) + app := newTestStorage(t) - withJobs := newAccount(t, app, "keeper@example.com") - empty := newAccount(t, app, "empty@example.com") + account := newAccount(t, app) + record := newRecordOf(t, app, account.Id) - job := newJob(t, repo, entity.StateCreated) - record, err := app.FindRecordById(migrations.JobsCollection, job.Id) - require.NoError(t, err) - record.Set("owner", withJobs) - require.NoError(t, app.Save(record)) + err := app.Delete(account) + require.Error(t, err, "учётная запись с архивом не удаляется") + assert.Contains(t, err.Error(), "остались записи", "отказ называет причину") - keeper, err := app.FindRecordById(migrations.UsersCollection, withJobs) - require.NoError(t, err) - - err = app.Delete(keeper) - require.Error(t, err, "учётная запись с задачами не удаляется") - - // Отказ обязан быть ошибкой роутера, а не обычной: наружу библиотека - // пропускает только её, а всякую другую подменяет своим сообщением про - // обязательную связь — подсказкой, по которой владелец панели пойдёт удалять - // задачи и файлы руками. Проверяется поэтому тип и текст, а не сам факт - // отказа: на потерянном сообщении факт остаётся прежним. - var apiErr *router.ApiError - require.ErrorAs(t, err, &apiErr, "отказ доезжает до владельца панели") - assert.Contains(t, apiErr.Message, "остались записи", - "причина названа, а не подменена библиотечной") - - // Задача и её владелец остались прежними. - after, err := app.FindRecordById(migrations.JobsCollection, job.Id) - require.NoError(t, err) - assert.Equal(t, withJobs, after.GetString("owner"), "владелец не снят") - - free, err := app.FindRecordById(migrations.UsersCollection, empty) - require.NoError(t, err) - assert.NoError(t, app.Delete(free), "учётная запись без записей удаляется") + after, err := NewAudioRecordRepository(app).Get(record.Id) + require.NoError(t, err, "запись на месте") + require.NotNil(t, after.OwnerID, "и владелец у неё прежний") + assert.Equal(t, account.Id, *after.OwnerID) } -// Файл переживает свою задачу: шаг конвейера заводит его до сохранения задачи, и -// потерянный захват оставляет файл с владельцем и без ссылки. Считать одни -// задачи значило бы отдать такое аудио на молчаливое обезличивание. -func TestGuardOwnerDeletion_CountsFilesToo(t *testing.T) { - app := newTestApp(t) - repo := NewFileRepository(app) +// Считаются все коллекции с владельцем, а не одни записи: файл переживает свою +// запись, а тема живёт в словаре человека. +func TestGuardOwnerDeletionCountsEveryOwnedCollection(t *testing.T) { + cases := map[string]func(t *testing.T, app core.App, ownerID string){ + "аудиозапись": func(t *testing.T, app core.App, ownerID string) { + newRecordOf(t, app, ownerID) + }, + "один файл без записи": func(t *testing.T, app core.App, ownerID string) { + repo := NewFileRepository(app) + work, err := repo.Stage(".mp3", strings.NewReader("запись")) + require.NoError(t, err) + defer func() { require.NoError(t, work.Close()) }() - owner := newAccount(t, app, "files@example.com") + _, err = repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, ownerID) + require.NoError(t, err) + }, + "одна тема словаря": func(t *testing.T, app core.App, ownerID string) { + topics, err := app.FindCollectionByNameOrId(migrations.TopicsCollection) + require.NoError(t, err) - work, err := repo.Stage(".ogg", strings.NewReader("запись")) - require.NoError(t, err) - defer func() { require.NoError(t, work.Close()) }() + topic := core.NewRecord(topics) + topic.Set("owner", ownerID) + topic.Set("name", "личная тема") + require.NoError(t, app.Save(topic)) + }, + } - _, err = repo.CreateLocal("sample.ogg", work, owner) - require.NoError(t, err) + for name, own := range cases { + t.Run(name, func(t *testing.T) { + app := newTestStorage(t) + account := newAccount(t, app) + own(t, app, account.Id) - record, err := app.FindRecordById(migrations.UsersCollection, owner) + err := app.Delete(account) + require.Error(t, err, "учётная запись с этим добром не удаляется") + assert.Contains(t, err.Error(), "остались записи", + "отказ наш, а не подсказка библиотеки про обязательную связь") + }) + } +} + +// Учётная запись, за которой ничего не числится, удаляется штатно: страж +// заведён против потери архива, а не против удаления вообще. +func TestGuardOwnerDeletionLetsEmptyAccountGo(t *testing.T) { + app := newTestStorage(t) + + account := newAccount(t, app) + require.NoError(t, app.Delete(account), "пустая учётная запись удаляется") +} + +// Умолчания у колонки владельца нет: запись, чей владелец не назван, не +// достаётся никому по недосмотру схемы. +func TestOwnerColumnHasNoDefault(t *testing.T) { + app := newTestStorage(t) + + records, err := app.FindCollectionByNameOrId(migrations.RecordsCollection) require.NoError(t, err) - err = app.Delete(record) - require.Error(t, err, "учётная запись с одними файлами тоже не удаляется") + field := records.Fields.GetByName("owner") + require.NotNil(t, field, "колонка владельца заведена") - after, err := app.FindAllRecords(migrations.FilesCollection) - require.NoError(t, err) - require.Len(t, after, 1) - assert.Equal(t, owner, after[0].GetString("owner"), "владелец файла не снят") + relation, ok := field.(*core.RelationField) + require.True(t, ok, "владелец — связь с учётной записью, а не строка") + assert.False(t, relation.Required, "пустое значение допустимо ради записей бота") + assert.False(t, relation.CascadeDelete, "удаление учётной записи не уносит архив следом") } diff --git a/internal/adapter/repo/pocketbase/panel.go b/internal/adapter/repo/pocketbase/panel.go index 5908189..0e2183f 100644 --- a/internal/adapter/repo/pocketbase/panel.go +++ b/internal/adapter/repo/pocketbase/panel.go @@ -4,35 +4,83 @@ import ( "github.com/pocketbase/pocketbase/core" "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" + "git.vakhrushev.me/av/transcriber/internal/entity" ) -// BindPanelRules подчиняет правку задачи в панели тем же правилам перехода, что -// и правку из кода. +// BindPanelRules подчиняет правку записи в панели тем же правилам, что и правку +// из кода. // -// Панель — вход в задачу наравне с конвейером, а не окно просмотра: ради правки -// она и покупалась, мёртвая задача оживляется сменой состояния. Но правка полем -// идёт мимо кода, который чистит служебные поля прошлого состояния, и владелец, -// «вернувший задачу в работу», получил бы задачу с прежним признаком захвата -// (захвату она не выдастся до конца срока) и с числом попыток на пределе (умрёт -// от первого же отказа). Узнать об этом ему неоткуда. +// Панель — вход в запись наравне с конвейером, а не окно просмотра: ради правки +// она и покупалась, остановленная запись возвращается в работу снятием признака. +// Но правка полем идёт мимо кода, который чистит служебные поля, и владелец, +// «вернувший запись в работу», получил бы запись с прежним признаком захвата +// (захвату она не выдастся до конца срока), с числом отказов на пределе +// (остановится от первого же отказа) и со старым временем входа в рубеж +// (остановится снова первым же захватом по пределу простоя). Узнать об этом ему +// неоткуда. +// +// Правило живёт **одним местом** — доменными `Resume` и `MoveToState`, — и хук +// зовёт именно их, а не повторяет перечень служебных полей колонками. Повтор +// перечня был бы вторым домом того же правила: новый сторож попал бы в домен и +// не попал в панель, и владелец «вернул бы запись в работу», а она снова выпала +// бы из выборки — молча. // // Хук стоит на правке **запросом**, а не на всяком сохранении записи. Модельное // событие не различает, кто пишет, и срабатывало бы на каждом переходе -// конвейера: тогда задержка, поставленная шагом вместе со сменой состояния, -// стиралась бы тем же сохранением, а число попыток мёртвой задачи — которое -// переход хранит намеренно — приходило бы владельцу нулём. +// конвейера: тогда пауза, поставленная шагом вместе со сменой рубежа, стиралась +// бы тем же сохранением, а число отказов остановленной записи — которое +// остановка хранит намеренно — приходило бы владельцу нулём. func BindPanelRules(app core.App) { - app.OnRecordUpdateRequest(migrations.JobsCollection).BindFunc(func(e *core.RecordRequestEvent) error { + app.OnRecordUpdateRequest(migrations.RecordsCollection).BindFunc(func(e *core.RecordRequestEvent) error { original := e.Record.Original() - if original == nil || original.GetString("state") == e.Record.GetString("state") { + if original == nil { return e.Next() } - e.Record.Set("acquisition_id", "") - e.Record.Set("acquire_time", "") - e.Record.Set("delay_time", "") - e.Record.Set("attempts", 0) + stateChanged := original.GetString("state") != e.Record.GetString("state") + // Снятие признака остановки — то самое движение, ради которого признак и + // заведён: запись возвращается в работу с сохранённого рубежа. + resumed := !original.GetDateTime("halted_at").IsZero() && + e.Record.GetDateTime("halted_at").IsZero() - return e.Next() + if !stateChanged && !resumed { + return e.Next() + } + + // Запись читается уже с правкой человека: рубеж здесь тот, который он + // выбрал, а признак остановки — тот, который он снял или оставил. + record := recordToAudioRecord(e.Record) + switch { + case resumed: + record.Resume() + default: + record.MoveToState(record.State) + } + applyOwnedByPipeline(e.Record, record) + + if err := e.Next(); err != nil { + return err + } + + if resumed { + // Перезапуск виден в журнале событий с указанием, что его сделал + // человек: иначе запись, вернувшаяся в работу, выглядела бы как + // запись, которая туда и не уходила. + // + // Строка пишется **после** сохранения: событие о правке, которая не + // прошла, соврало бы о состоянии записи. Отказ записи журнала саму + // правку не отменяет — журнал никем не читается ради решения. + event := &entity.RecordEvent{ + RecordID: e.Record.Id, + Origin: entity.EventOriginHuman, + Step: "resume", + Outcome: entity.EventOutcomeResumed, + } + if err := NewRecordEventRepository(e.App).Append(event); err != nil { + e.App.Logger().Error("Failed to log record resume", "error", err, "record_id", e.Record.Id) + } + } + + return nil }) } diff --git a/internal/adapter/repo/pocketbase/panel_test.go b/internal/adapter/repo/pocketbase/panel_test.go new file mode 100644 index 0000000..23d6cbc --- /dev/null +++ b/internal/adapter/repo/pocketbase/panel_test.go @@ -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, "признак нового захвата отличается от прежнего") +} diff --git a/internal/adapter/repo/pocketbase/recognition_repo.go b/internal/adapter/repo/pocketbase/recognition_repo.go new file mode 100644 index 0000000..cf339e8 --- /dev/null +++ b/internal/adapter/repo/pocketbase/recognition_repo.go @@ -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 +} diff --git a/internal/adapter/repo/pocketbase/record_event_repo.go b/internal/adapter/repo/pocketbase/record_event_repo.go new file mode 100644 index 0000000..69c9826 --- /dev/null +++ b/internal/adapter/repo/pocketbase/record_event_repo.go @@ -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 +} diff --git a/internal/adapter/repo/pocketbase/record_mapping.go b/internal/adapter/repo/pocketbase/record_mapping.go new file mode 100644 index 0000000..8a45d7a --- /dev/null +++ b/internal/adapter/repo/pocketbase/record_mapping.go @@ -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 +} diff --git a/internal/adapter/repo/pocketbase/record_repo.go b/internal/adapter/repo/pocketbase/record_repo.go new file mode 100644 index 0000000..0c54ff7 --- /dev/null +++ b/internal/adapter/repo/pocketbase/record_repo.go @@ -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 +} diff --git a/internal/adapter/repo/pocketbase/schema_test.go b/internal/adapter/repo/pocketbase/schema_test.go new file mode 100644 index 0000000..2c6afef --- /dev/null +++ b/internal/adapter/repo/pocketbase/schema_test.go @@ -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) + } + } +} diff --git a/internal/adapter/repo/pocketbase/text_repo.go b/internal/adapter/repo/pocketbase/text_repo.go new file mode 100644 index 0000000..f17db2e --- /dev/null +++ b/internal/adapter/repo/pocketbase/text_repo.go @@ -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 +} diff --git a/internal/adapter/repo/pocketbase/transcript_job_repo.go b/internal/adapter/repo/pocketbase/transcript_job_repo.go deleted file mode 100644 index 6c8d259..0000000 --- a/internal/adapter/repo/pocketbase/transcript_job_repo.go +++ /dev/null @@ -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 -} diff --git a/internal/adapter/repo/pocketbase/transcript_job_repo_test.go b/internal/adapter/repo/pocketbase/transcript_job_repo_test.go deleted file mode 100644 index e26b5ac..0000000 --- a/internal/adapter/repo/pocketbase/transcript_job_repo_test.go +++ /dev/null @@ -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 -} diff --git a/internal/archrules/arch_test.go b/internal/archrules/arch_test.go index 396d6f0..4858254 100644 --- a/internal/archrules/arch_test.go +++ b/internal/archrules/arch_test.go @@ -174,186 +174,211 @@ func TestОшибкаНеУзнаётсяПоТексту(t *testing.T) { } } -// Колонки очереди правятся в четырёх местах пакета хранилища плюс шаг схемы, и -// компилятор видит два из них (инвариант CLAUDE.md, «Инварианты», major). -// Колонка, забытая в паре `acquireColumns`/`acquiredRow`, приезжает из захвата -// нулевой, и первый же `Save` пишет этот ноль поверх сохранённого значения — -// поле теряется только у задачи, попавшей к воркеру. +// Перечень колонок аудиозаписи компилятор не видит: их пишет `applyOwnedByPipeline`, +// читает `recordToAudioRecord`, и заводит шаг схемы. Колонка, забытая в паре +// «пишем — читаем», теряется молча: запись, прочитанная не тем путём, приезжает +// с нулевым полем, и первое же сохранение пишет этот ноль поверх значения. // -// Правила ниже закрывают все четыре места плюс шаг схемы: перечень запроса, -// структуру захвата, запись коллекции (`applyToRecord`/`recordToJob`) и перенос -// поля в задачу (`toJob`). Литерал колонки ищется **в телах** нужных функций, а -// не в файле: файл держит и структуру с тегами `db:"…"`, и по ней условие -// выполнялось бы само собой. +// Мест стало **два** вместо прежних четырёх: захват больше не перечисляет +// колонки поимённо, а возвращает идентификатор и признак своего захвата. Правила +// ниже держат оставшуюся пару плюс шаг схемы. const ( repoPkg = "internal/adapter/repo/pocketbase" - acquireFile = repoPkg + "/transcript_job_repo.go" - mappingFile = repoPkg + "/job_mapping.go" + mappingFile = repoPkg + "/record_mapping.go" migrationsPath = repoPkg + "/migrations" + stageFile = "internal/entity/stage.go" + stateFile = "internal/entity/audio_record.go" + serviceFile = "internal/service/transcribe.go" ) -// Колонки, которые заводит и заполняет само хранилище: перечня запроса они -// касаются, а нашего кода — нет. +// Колонки, которые заводит и заполняет само хранилище: нашего кода они не +// касаются. var storageOwned = map[string]bool{"id": true, "created": true, "updated": true} -func TestПереченьЗахватаСовпадаетСоСтруктурой(t *testing.T) { - query := acquireColumnNames(t) - row := rowColumnNames(t) +func TestКолонкиЗаписиПишутсяИЧитаются(t *testing.T) { + written := writtenColumns(t) + read := readColumns(t) - for _, col := range query { - if !row[col] { + for col := range written { + if storageOwned[col] { + continue + } + if !read[col] { t.Errorf( - "колонка %q есть в acquireColumns, но не в acquiredRow: из захвата "+ - "она приедет нулевой, и первый Save затрёт сохранённое значение", + "колонку %q пишет отображение записи, но recordToAudioRecord её не "+ + "читает: запись приедет из хранилища без этого поля", col, ) } - delete(row, col) } - for col := range row { - t.Errorf( - "колонка %q есть в acquiredRow, но не в acquireColumns: запрос её не "+ - "читает, и поле остаётся нулевым", - col, - ) + for col := range read { + if storageOwned[col] { + continue + } + if !written[col] { + t.Errorf( + "колонку %q читает recordToAudioRecord, но её не пишет ни "+ + "applyOwnedByPipeline, ни applyToRecord: поле не сохранится", + col, + ) + } } } -func TestКолонкиЗахватаЗаведеныШагомСхемы(t *testing.T) { +func TestКолонкиЗаписиЗаведеныШагомСхемы(t *testing.T) { declared := schemaFieldNames(t) - for _, col := range acquireColumnNames(t) { - if col == "id" { - continue // ключ заводит само хранилище, шаг схемы его не объявляет + for col := range writtenColumns(t) { + if storageOwned[col] { + continue } if !declared[col] { t.Errorf( - "колонка %q читается захватом, но ни один шаг схемы её не заводит: "+ - "запрос отвалится на живой базе", + "колонка %q пишется отображением записи, но ни один шаг схемы её не "+ + "заводит: сохранение отвалится на живой базе", col, ) } } } -// Четвёртое место — путь через запись коллекции: `applyToRecord` пишет колонку, -// `recordToJob` читает. Ищется литерал **в телах этих функций**, а не в файле: -// в файле лежит и структура захвата со своими тегами `db:"…"`, и по ней условие -// выполнялось бы само собой — правило было бы зелёным всегда. -func TestКолонкиЗахватаЧитаютсяИЧерезЗапись(t *testing.T) { - write := funcBody(t, mappingFile, "func applyOwnedByPipeline(") + +// Рубеж объявлен одним дескриптором, но шаг под него пишется в другом месте, и +// связь между ними компилятор не видит. Рубеж, оставшийся без шага, из работы не +// выходит: воркер его захватит, шага не найдёт и остановит запись — а рубеж, +// забытый в дескрипторе, не выдаётся захвату вовсе, и пустой прогон по +// инварианту проекта не пишется в журнал и не считается в метрику. +func TestУКаждогоРабочегоРубежаЕстьШаг(t *testing.T) { + body := funcBody(t, serviceFile, "func (s *TranscribeService) stepFor(") + for _, stage := range workingStageIdents(t) { + if !strings.Contains(body, "entity."+stage) { + t.Errorf( + "рубеж entity.%s объявлен рабочим в дескрипторе, но шага под него нет "+ + "в таблице stepFor: запись с этим рубежом остановится, не начав работы", + stage, + ) + } + } +} + +// Обратное направление того же правила: шаг, написанный под рубеж, которого в +// дескрипторе нет, недостижим — захват такую запись не выдаст никогда. +func TestШагиОбъявленыРубежамиДескриптора(t *testing.T) { + body := funcBody(t, serviceFile, "func (s *TranscribeService) stepFor(") + declared := map[string]bool{} + for _, stage := range stageIdents(t) { + declared[stage] = true + } + + re := regexp.MustCompile(`case entity\.(\w+):`) + for _, m := range re.FindAllStringSubmatch(body, -1) { + if !declared[m[1]] { + t.Errorf( + "в таблице stepFor есть ветка для entity.%s, но такого рубежа нет в "+ + "дескрипторе: запись с этим рубежом захвату не выдаётся", + m[1], + ) + } + } +} + +// writtenColumns — колонки, которые пишет отображение записи в хранилище. +func writtenColumns(t *testing.T) map[string]bool { + t.Helper() + body := funcBody(t, mappingFile, "func applyOwnedByPipeline(") + funcBody(t, mappingFile, "func applyToRecord(") - read := funcBody(t, mappingFile, "func recordToJob(") - - for _, col := range acquireColumnNames(t) { - if storageOwned[col] { - continue // эти колонки заводит и заполняет само хранилище - } - if !strings.Contains(write, `"`+col+`"`) { - t.Errorf( - "колонку %q читает захват, но её не пишет ни applyOwnedByPipeline, "+ - "ни applyToRecord: путь через запись коллекции её потеряет", - col, - ) - } - if !strings.Contains(read, `"`+col+`"`) { - t.Errorf( - "колонку %q читает захват, но recordToJob её не читает: задача, "+ - "прочитанная не захватом, приедет без этого поля", - col, - ) - } - } -} - -// Пятое условие того же инварианта: колонка, доехавшая до структуры захвата, -// обязана попасть в задачу. `toJob` обращается к **полям**, а не к литералам, -// поэтому сверяются имена полей, а не имена колонок: поле, забытое здесь, -// приезжает из захвата прочитанным и теряется на последнем шаге. -func TestПоляСтруктурыЗахватаДоезжаютДоЗадачи(t *testing.T) { - body := funcBody(t, mappingFile, "func (r *acquiredRow) toJob()") - for _, field := range rowFieldNames(t) { - if !strings.Contains(body, "r."+field) { - t.Errorf( - "поле %s структуры захвата не читается в toJob: колонка приедет из "+ - "запроса, но в задачу не попадёт", - field, - ) - } - } -} - -// --- Чтение исходников ------------------------------------------------------ - -// acquireColumnNames достаёт имена колонок из константы `acquireColumns`. Она -// склеена из строковых литералов, поэтому берётся текстом, а не разбором типов: -// значение константы известно на месте. -func acquireColumnNames(t *testing.T) []string { - t.Helper() - body := readFile(t, acquireFile) - const marker = "const acquireColumns = " - start := strings.Index(body, marker) - if start < 0 { - t.Fatalf("в %s нет константы acquireColumns: правило потеряло предмет", acquireFile) - } - tail := body[start+len(marker):] - end := strings.Index(tail, "`\n") - if end < 0 { - t.Fatalf("не нашёл конец константы acquireColumns в %s", acquireFile) - } - var cols []string - for _, chunk := range strings.Split(strings.NewReplacer("`", "", "+", "", "\n", "", "\t", "").Replace(tail[:end]), ",") { - if col := strings.TrimSpace(chunk); col != "" { - cols = append(cols, col) - } - } - if len(cols) == 0 { - t.Fatalf("перечень acquireColumns прочитан пустым: правило потеряло предмет") - } - return cols -} - -// rowColumnNames достаёт колонки из тегов `db:"…"` структуры `acquiredRow`. -func rowColumnNames(t *testing.T) map[string]bool { - t.Helper() out := map[string]bool{} - for _, m := range regexp.MustCompile("`db:\"([^\"]+)\"`").FindAllStringSubmatch(rowStruct(t), -1) { + for _, m := range regexp.MustCompile(`record\.Set\("([^"]+)"`).FindAllStringSubmatch(body, -1) { out[m[1]] = true } if len(out) == 0 { - t.Fatalf("у acquiredRow не прочитан ни один тег db: правило потеряло предмет") + t.Fatalf("отображение записи не пишет ни одной колонки: правило потеряло предмет") } return out } -// rowFieldNames достаёт имена полей структуры `acquiredRow` — те, к которым -// обращается `toJob`. -func rowFieldNames(t *testing.T) []string { +// readColumns — колонки, которые читает обратное отображение. +func readColumns(t *testing.T) map[string]bool { t.Helper() - var out []string - for _, m := range regexp.MustCompile(`(?m)^\t([A-Z]\w*)\s`).FindAllStringSubmatch(rowStruct(t), -1) { - out = append(out, m[1]) + body := funcBody(t, mappingFile, "func recordToAudioRecord(") + out := map[string]bool{} + for _, m := range regexp.MustCompile(`\.Get\w+\("([^"]+)"\)`).FindAllStringSubmatch(body, -1) { + out[m[1]] = true } if len(out) == 0 { - t.Fatalf("у acquiredRow не прочитано ни одно поле: правило потеряло предмет") + t.Fatalf("recordToAudioRecord не читает ни одной колонки: правило потеряло предмет") } return out } -// rowStruct — текст объявления структуры `acquiredRow`. -func rowStruct(t *testing.T) string { +// stageIdents — имена констант рубежей, перечисленных дескриптором. +func stageIdents(t *testing.T) []string { t.Helper() - body := readFile(t, mappingFile) - start := strings.Index(body, "type acquiredRow struct {") + out, _ := stageDescriptor(t) + return out +} + +// workingStageIdents — то же, но без конечного рубежа: из него запись в работу +// не берут. +func workingStageIdents(t *testing.T) []string { + t.Helper() + _, working := stageDescriptor(t) + return working +} + +func stageDescriptor(t *testing.T) (all []string, working []string) { + t.Helper() + body := readFile(t, stageFile) + const marker = "var stages = []Stage{" + start := strings.Index(body, marker) if start < 0 { - t.Fatalf("в %s нет структуры acquiredRow: правило потеряло предмет", mappingFile) + t.Fatalf("в %s нет дескриптора рубежей: правило потеряло предмет", stageFile) } end := strings.Index(body[start:], "\n}") if end < 0 { - t.Fatalf("не нашёл конец структуры acquiredRow в %s", mappingFile) + t.Fatalf("не нашёл конец дескриптора рубежей в %s", stageFile) } - return body[start : start+end] + + declared := declaredStates(t) + re := regexp.MustCompile(`\{Name: (\w+)[^}]*\}`) + for _, m := range re.FindAllStringSubmatch(body[start:start+end], -1) { + if !declared[m[1]] { + t.Errorf( + "дескриптор называет рубеж %s, которого нет среди объявленных состояний "+ + "в %s: перечень схемы разошёлся бы с ним молча", + m[1], stateFile, + ) + continue + } + all = append(all, m[1]) + if !strings.Contains(m[0], "Terminal: true") { + working = append(working, m[1]) + } + } + + if len(all) == 0 { + t.Fatalf("дескриптор рубежей прочитан пустым: правило потеряло предмет") + } + if len(working) == 0 { + t.Fatalf("в дескрипторе нет ни одного рабочего рубежа: правило потеряло предмет") + } + return all, working } +// declaredStates — константы рубежей, объявленные доменом. +func declaredStates(t *testing.T) map[string]bool { + t.Helper() + out := map[string]bool{} + re := regexp.MustCompile(`(?m)^\t(State\w+)\s*=\s*"`) + for _, m := range re.FindAllStringSubmatch(readFile(t, stateFile), -1) { + out[m[1]] = true + } + if len(out) == 0 { + t.Fatalf("в %s не объявлено ни одного рубежа: правило потеряло предмет", stateFile) + } + return out +} + +// --- Чтение исходников ------------------------------------------------------ + // funcBody — текст тела функции от её заголовка до закрывающей скобки в первой // позиции строки. Пропавший заголовок — отказ, а не пустое тело: правило, // потерявшее предмет, обязано краснеть, а не зеленеть. diff --git a/internal/config/config.go b/internal/config/config.go index 0a3cef8..50eb217 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,18 +7,59 @@ import ( "os" "sort" "strings" + "time" "github.com/BurntSushi/toml" + + "git.vakhrushev.me/av/transcriber/internal/entity" ) type Config struct { Server ServerConfig `toml:"server"` Storage StorageConfig `toml:"storage"` + Pipeline PipelineConfig `toml:"pipeline"` Yandex YandexConfig `toml:"yandex"` Telegram TelegramConfig `toml:"telegram"` Auth AuthConfig `toml:"auth"` } +// PipelineConfig — настройки конвейера расшифровки. +type PipelineConfig struct { + // Workers — число одинаковых воркеров. Ноль — законное значение: сервис + // поднимается, записи принимаются и не двигаются. Это режим, а не поломка. + Workers int `toml:"workers"` + // OwnWorkLimitMinutes — предел простоя записи там, где работу делаем мы + // сами. Сторож ловит **зависание**, а не долгую работу: живой шаг + // наблюдается по самому процессу, а остановка обратима — снятие признака + // возвращает запись на её рубеж. + OwnWorkLimitMinutes int `toml:"own_work_limit_minutes"` + // ForeignWorkLimitMinutes — предел простоя там, где ждём чужую операцию. + // Сколько идёт распознавание долгой записи, никто не мерил, поэтому ошибка + // идёт в сторону долгого: ложная остановка хуже поздней. + ForeignWorkLimitMinutes int `toml:"foreign_work_limit_minutes"` +} + +// StuckLimits переводит настройки в пределы простоя. +func (c PipelineConfig) StuckLimits() entity.StuckLimits { + return entity.StuckLimits{ + Own: time.Duration(c.OwnWorkLimitMinutes) * time.Minute, + Foreign: time.Duration(c.ForeignWorkLimitMinutes) * time.Minute, + } +} + +// Validate проверяет числа конвейера. Отрицательное число воркеров — ошибка +// настройки, а не режим: ноль объявлен законным значением, и отличать его от +// опечатки обязан старт. +func (c PipelineConfig) Validate() error { + if c.Workers < 0 { + return errors.New("pipeline: число воркеров не может быть отрицательным") + } + if c.OwnWorkLimitMinutes <= 0 || c.ForeignWorkLimitMinutes <= 0 { + return errors.New("pipeline: пределы простоя задаются положительным числом минут") + } + return nil +} + type ServerConfig struct { Port int `toml:"port"` ShutdownTimeout int `toml:"shutdown_timeout"` @@ -143,6 +184,15 @@ func defaultConfig() *Config { Storage: StorageConfig{ DataDir: "data", }, + Pipeline: PipelineConfig{ + Workers: 3, + // Час на свою работу и сутки на чужую. Час меньше времени, которое + // многочасовая запись занимает на приведении, и это принято + // сознательно: сторож ловит зависание, живой шаг наблюдается по + // самому процессу, а остановка обратима. + OwnWorkLimitMinutes: 60, + ForeignWorkLimitMinutes: 24 * 60, + }, Yandex: YandexConfig{ FolderID: "", SpeechKitAPIKey: "", diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1f3f380..89b8874 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) // Проверка входа — единственная страховка от того, чтобы сервис поднялся с @@ -274,3 +275,71 @@ func TestLoadConfigMalformedBeforeAnyKeyHidesValue(t *testing.T) { t.Fatalf("место отказа не названо, чинить нечего: %v", err) } } + +// Числа конвейера приезжают из файла, а не выдумываются кодом. +func TestLoadConfigReadsPipelineSettings(t *testing.T) { + path := writeConfig(t, ` +[pipeline] +workers = 7 +own_work_limit_minutes = 90 +foreign_work_limit_minutes = 720 + +[telegram] +enabled = false +`) + + cfg, err := LoadConfig(path) + if err != nil { + t.Fatalf("конфиг не прочитан: %v", err) + } + + if cfg.Pipeline.Workers != 7 { + t.Errorf("число воркеров не прочитано: %d", cfg.Pipeline.Workers) + } + + limits := cfg.Pipeline.StuckLimits() + if limits.Own != 90*time.Minute { + t.Errorf("предел своей работы не прочитан: %v", limits.Own) + } + if limits.Foreign != 720*time.Minute { + t.Errorf("предел чужой работы не прочитан: %v", limits.Foreign) + } +} + +// Умолчания есть у всех трёх чисел: файл без секции конвейера годен, и сервис +// поднимается с рабочими значениями. +func TestPipelineSettingsHaveDefaults(t *testing.T) { + path := writeConfig(t, "[telegram]\nenabled = false\n") + + cfg, err := LoadConfig(path) + if err != nil { + t.Fatalf("конфиг не прочитан: %v", err) + } + + if cfg.Pipeline.Workers <= 0 { + t.Errorf("умолчание числа воркеров негодно: %d", cfg.Pipeline.Workers) + } + if err := cfg.Pipeline.Validate(); err != nil { + t.Errorf("умолчания не проходят собственную проверку: %v", err) + } +} + +// Ноль воркеров — объявленный режим, а отрицательное число и нулевой предел — +// опечатка: подниматься с ней значит остановить всякую запись первым же +// захватом. +func TestPipelineValidateSeparatesModeFromTypo(t *testing.T) { + valid := PipelineConfig{Workers: 0, OwnWorkLimitMinutes: 60, ForeignWorkLimitMinutes: 1440} + if err := valid.Validate(); err != nil { + t.Errorf("ноль воркеров объявлен законным значением: %v", err) + } + + for name, cfg := range map[string]PipelineConfig{ + "отрицательное число воркеров": {Workers: -1, OwnWorkLimitMinutes: 60, ForeignWorkLimitMinutes: 1440}, + "нулевой предел своей работы": {Workers: 1, OwnWorkLimitMinutes: 0, ForeignWorkLimitMinutes: 1440}, + "нулевой предел чужой работы": {Workers: 1, OwnWorkLimitMinutes: 60, ForeignWorkLimitMinutes: 0}, + } { + if err := cfg.Validate(); err == nil { + t.Errorf("%s принято за режим", name) + } + } +} diff --git a/internal/contract/contract.go b/internal/contract/contract.go index 4802621..d3ef390 100644 --- a/internal/contract/contract.go +++ b/internal/contract/contract.go @@ -24,10 +24,39 @@ type AudioFileConverter interface { Convert(ctx context.Context, src, dest string) error } +// AudioRecognizer — внешний распознаватель речи. +// +// Заливка и отправка операции разделены: это два обращения с разной ценой +// повтора. Повтор заливки бесплатен и кладёт объект под тем же ключом; повтор +// отправки оплачивается наружу, и шаг обязан проверить сделанное прежде, чем +// платить второй раз. +// +// Результат отдаётся **доменным** — реплики со временем, плоский текст и байты +// ответа на хранение, — а не сырым форматом провайдера: разбор потока это +// обязанность адаптера, и ни один шаг конвейера не знает, каким потоком и какими +// полями провайдер отвечает. type AudioRecognizer interface { - Recognize(ctx context.Context, file io.Reader, fileName string) (operationID string, err error) - GetRecognitionText(ctx context.Context, operationID string) (string, error) - CheckRecognitionStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) + // Provider — имя провайдера, под которым сохраняется попытка. + Provider() string + // Model — имя модели распознавания. + Model() string + // Upload кладёт аудио туда, откуда провайдер его прочитает, и отдаёт адрес. + // Повтор кладёт объект под тем же ключом и оплаты не стоит. + Upload(ctx context.Context, file io.Reader, objectKey string) (sourceURI string, err error) + // ObjectExists отвечает, лежит ли объект нужного размера. По нему шаг решает, + // повторять ли заливку; сверка содержимого хешем ненадёжна — признак + // целостности у составного объекта не равен отпечатку содержимого. + ObjectExists(ctx context.Context, objectKey string, size int64) (bool, error) + // Submit заводит операцию распознавания по адресу аудио. Оплачивается + // наружу. + Submit(ctx context.Context, sourceURI string) (operationID string, err error) + // CheckStatus опрашивает операцию. + CheckStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) + // Fetch забирает готовый результат и отдаёт его доменным. + Fetch(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error) + // Parse строит доменный результат из **сохранённого** ответа провайдера, не + // обращаясь к нему. По нему архив пересчитывается без единого рубля. + Parse(raw []byte) (*entity.RecognitionOutcome, error) } type TelegramMessageSender interface { diff --git a/internal/contract/repository.go b/internal/contract/repository.go index e6384c5..0576b20 100644 --- a/internal/contract/repository.go +++ b/internal/contract/repository.go @@ -2,7 +2,6 @@ package contract import ( "io" - "time" "git.vakhrushev.me/av/transcriber/internal/entity" ) @@ -23,6 +22,14 @@ type WorkFile interface { Close() error } +// FileMeta — что известно о копии сверх её содержимого. +type FileMeta struct { + // Format — расширение без точки, в нижнем регистре. + Format string + // DurationMs — длительность, если её удалось прочитать. + DurationMs int64 +} + type FileRepository interface { // Stage принимает содержимое потоком в рабочую копию с заданным // расширением: по нему внешняя программа выбирает разбор. В память запись @@ -33,44 +40,96 @@ type FileRepository interface { StageEmpty(ext string) (WorkFile, error) // Localize выдаёт рабочую копию хранимого файла. Localize(fileID string) (WorkFile, error) - // CreateLocal кладёт рабочую копию в хранилище под именем name и заводит - // запись о файле. Имя задаёт сервис: умолчание хранилища, строящее его из - // имени отправителя, не применяется. + // Create кладёт рабочую копию в хранилище под именем name и заводит запись о + // файле. Имя задаёт сервис: умолчание хранилища, строящее его из имени + // отправителя, не применяется. // // ownerID — владелец записи, которой файл принадлежит; пустой значит «файл // без владельца», и таков всякий файл записи, принятой ботом. Владелец - // лежит своей колонкой, а не выводится через задачу: ссылку на файл в - // задаче переставляет каждый шаг конвейера, и исходная копия после - // конвертации не связана с задачей ничем. - CreateLocal(name string, work WorkFile, ownerID string) (*entity.File, error) - // CreateRemote заводит запись о копии, лежащей во внешнем хранилище. - CreateRemote(objectKey string, size int64, ownerID string) (*entity.File, error) + // лежит своей колонкой, а не выводится через запись: файл переживает свою + // запись — шаг заводит его до сохранения, и потерянный захват оставляет файл + // с владельцем и без ссылки. + Create(name string, work WorkFile, meta FileMeta, ownerID string) (*entity.File, error) GetByID(id string) (*entity.File, error) // Open отдаёт содержимое хранимого файла потоком. Open(fileID string) (io.ReadCloser, error) } -type TranscriptJobRepository interface { - Create(job *entity.TranscribeJob) error - // Save сохраняет задачу, захват которой держит holder. Захват, доставшийся +// AcquiredRecord — то, что отдаёт захват: идентификатор записи и признак +// **этого** захвата. +// +// Перечня колонок здесь нет намеренно. Захват, возвращавший колонки поимённо, +// требовал править их в четырёх местах сразу, и забытая колонка приезжала +// нулевой, а первое же сохранение писало этот ноль поверх значения. Колонки шаг +// читает обычным чтением. +type AcquiredRecord struct { + ID string + // Holder — значение, уникальное для каждого захвата. Запись результата + // условна по нему, а не по занятости записи: захват, перевыданный другому по + // протуханию срока или после снятия остановки человеком, обязан обратить + // запись первого в отказ. + Holder string +} + +type AudioRecordRepository interface { + Create(record *entity.AudioRecord) error + // Save сохраняет запись, захват которой держит holder. Захват, доставшийся // за время работы другому, даёт LostAcquisitionError и запись не проводит. // Пустой holder снимает эту условность и в конвейере не употребляется: все // его шаги получают признак захвата от FindAndAcquire. - Save(job *entity.TranscribeJob, holder string) error - // GetByID отдаёт задачу, только если её владелец — ownerID. Чужая задача, - // ничья задача и несуществующая дают одну и ту же ошибку: по разнице - // ответов иначе перебирается список заведённых задач. + Save(record *entity.AudioRecord, holder string) error + // GetByID отдаёт запись, только если её владелец — ownerID. Чужая запись, + // ничья и несуществующая дают одну и ту же ошибку: по разнице ответов иначе + // перебирается список заведённых записей. // // Владелец здесь обязателен, и пустой ownerID не совпадает ни с чем — - // включая задачи без владельца. Правило записано со стороны спрашивающего: + // включая записи без владельца. Правило записано со стороны спрашивающего: // обязательность, которую держит одна лишь подпись метода, пустую строку - // пропускает, и вызывающий без учётной записи получил бы ровно множество - // записей бота. + // пропускает. + GetByID(id, ownerID string) (*entity.AudioRecord, error) + // Get отдаёт запись без сужения владельцем: им пользуется конвейер, чья + // выборка владельцем не сужается. + Get(id string) (*entity.AudioRecord, error) + // FindAndAcquire забирает пригодную к работе запись одним неделимым шагом и + // увеличивает число её отказов. Отбор идёт по рабочим рубежам, паузе, сроку + // протухания захвата и отсутствию признака остановки; срок протухания + // приезжает с рубежом и пишется в саму запись. // - // Второго читающего метода нет намеренно: он выбирался бы по - // внимательности вызывающего. - GetByID(id, ownerID string) (*entity.TranscribeJob, error) - // FindAndAcquire забирает задачу одним неделимым шагом и увеличивает число - // её попыток. Работы в состоянии нет — JobNotFoundError. - FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error) + // Работы нет — JobNotFoundError. + FindAndAcquire(stages []entity.Stage) (*AcquiredRecord, error) +} + +// TextRepository — тексты записи. Пара «запись и вид» уникальна: повтор +// прерванного шага не заводит второй строки. +type TextRepository interface { + Put(recordID, kind, contents string) (*entity.Text, error) + GetByID(id string) (*entity.Text, error) +} + +// StructureRepository — структура реплик записи. Пара «запись и версия разбора» +// уникальна по той же причине. +type StructureRepository interface { + Put(recordID string, version int, replicas []entity.Replica) (*entity.Structure, error) + GetByID(id string) (*entity.Structure, error) +} + +// RecognitionRepository — попытка распознавания у внешнего провайдера. +type RecognitionRepository interface { + // Create заводит строку попытки **до** обращения к провайдеру: окно между + // его ответом и записью идентификатора — то место, где теряется оплаченное. + Create(recognition *entity.Recognition) error + // Submitted сохраняет адрес аудио и идентификатор заведённой операции. По + // последнему повторный шаг узнаёт, что за эту запись уже заплачено. + Submitted(id, sourceURI, externalID string) error + // Finish отмечает завершение операции и кладёт сырой ответ вложением. + Finish(id string, raw []byte) error + GetByID(id string) (*entity.Recognition, error) + // ReadRaw отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда + // ответ нужен: шаг опроса читает строку попытки без него. + ReadRaw(id string) ([]byte, error) +} + +// RecordEventRepository — журнал событий записи. +type RecordEventRepository interface { + Append(event *entity.RecordEvent) error } diff --git a/internal/controller/http/auth_test.go b/internal/controller/http/auth_test.go index e6234d7..aa98765 100644 --- a/internal/controller/http/auth_test.go +++ b/internal/controller/http/auth_test.go @@ -38,7 +38,7 @@ func TestApiRequiresSession(t *testing.T) { require.NoError(t, err) assert.Empty(t, files) - jobs, err := env.app.FindAllRecords(migrations.JobsCollection) + jobs, err := env.app.FindAllRecords(migrations.RecordsCollection) require.NoError(t, err) assert.Empty(t, jobs) }) @@ -64,7 +64,7 @@ func TestUnknownJobIsIndistinguishableWithoutSession(t *testing.T) { env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio"))) require.Equal(t, http.StatusCreated, created.Code) - jobs, err := env.app.FindAllRecords(migrations.JobsCollection) + jobs, err := env.app.FindAllRecords(migrations.RecordsCollection) require.NoError(t, err) require.Len(t, jobs, 1) diff --git a/internal/controller/http/login_test.go b/internal/controller/http/login_test.go index 073ad37..460d7a8 100644 --- a/internal/controller/http/login_test.go +++ b/internal/controller/http/login_test.go @@ -188,7 +188,7 @@ func TestLoginCreatesAccountAndSession(t *testing.T) { r, err := apis.NewRouter(env.app) require.NoError(t, err) - NewTranscribeHandler(pbrepo.NewTranscriptJobRepository(env.app), nil, nil).Register(r) + NewTranscribeHandler(pbrepo.NewAudioRecordRepository(env.app), pbrepo.NewTextRepository(env.app), nil, nil).Register(r) checkMux, err := r.BuildMux() require.NoError(t, err) checkMux.ServeHTTP(checkResponse, check) diff --git a/internal/controller/http/ownership_test.go b/internal/controller/http/ownership_test.go index 22f4390..334cf20 100644 --- a/internal/controller/http/ownership_test.go +++ b/internal/controller/http/ownership_test.go @@ -12,6 +12,9 @@ import ( "github.com/stretchr/testify/require" pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" + "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" + "git.vakhrushev.me/av/transcriber/internal/clock" + "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" ) @@ -74,7 +77,7 @@ func TestGetTranscribeJobStatus_ForeignJobLooksMissing(t *testing.T) { assert.JSONEq(t, unknown.Body.String(), foreign.Body.String(), "и тело то же") // Ни состояния, ни текста расшифровки в теле нет. - assert.NotContains(t, foreign.Body.String(), entity.StateCreated) + assert.NotContains(t, foreign.Body.String(), entity.StateUploaded) assert.NotContains(t, foreign.Body.String(), "transcription_text") } @@ -88,15 +91,16 @@ func TestGetTranscribeJobStatus_OwnerlessJobLooksMissing(t *testing.T) { defer func() { require.NoError(t, work.Close()) }() // Файл записи из Telegram владельца тоже не имеет. - file, err := fileRepo.CreateLocal("voice.ogg", work, "") + file, err := fileRepo.Create("voice.ogg", work, contract.FileMeta{Format: "ogg"}, "") require.NoError(t, err) - job := &entity.TranscribeJob{ - State: entity.StateCreated, - Source: entity.SourceTelegram, - FileID: &file.Id, + job := &entity.AudioRecord{ + State: entity.StateUploaded, + StateEnteredAt: clock.Now(), + Source: entity.SourceTelegram, + OriginalFileID: &file.Id, } - require.NoError(t, env.handler.jobRepo.Create(job)) + require.NoError(t, env.handler.recordRepo.Create(job)) w := httptest.NewRecorder() env.serve(w, httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)) @@ -116,11 +120,11 @@ func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) { var response CreateTranscribeJobResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - record, err := env.app.FindRecordById("transcribe_jobs", response.JobID) + record, err := env.app.FindRecordById(migrations.RecordsCollection, response.JobID) require.NoError(t, err) assert.Equal(t, env.account.Id, record.GetString("owner"), "владелец задачи — предъявитель") - fileRecord, err := env.app.FindRecordById("files", record.GetString("file")) + fileRecord, err := env.app.FindRecordById("files", record.GetString("original_file")) require.NoError(t, err) assert.Equal(t, env.account.Id, fileRecord.GetString("owner"), "владелец файла — он же") } @@ -143,7 +147,7 @@ func TestCreateTranscribeJob_OwnerFieldFromRequestIgnored(t *testing.T) { var response CreateTranscribeJobResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - record, err := env.app.FindRecordById("transcribe_jobs", response.JobID) + record, err := env.app.FindRecordById(migrations.RecordsCollection, response.JobID) require.NoError(t, err) assert.Equal(t, env.account.Id, record.GetString("owner")) } @@ -187,7 +191,7 @@ func TestFileDownload_NarrowedByOwner(t *testing.T) { job := jobWithFile(t, env) _, stranger := newSecondAccount(t, env.app) - record, err := env.app.FindRecordById("files", *job.FileID) + record, err := env.app.FindRecordById("files", *job.OriginalFileID) require.NoError(t, err) require.Equal(t, env.account.Id, record.GetString("owner")) diff --git a/internal/controller/http/status_test.go b/internal/controller/http/status_test.go new file mode 100644 index 0000000..b0f8e66 --- /dev/null +++ b/internal/controller/http/status_test.go @@ -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", + "владелец сервиса узнаёт об аварии из журнала") +} diff --git a/internal/controller/http/transcribe.go b/internal/controller/http/transcribe.go index 9593d25..3ef3d65 100644 --- a/internal/controller/http/transcribe.go +++ b/internal/controller/http/transcribe.go @@ -18,16 +18,22 @@ import ( ) type TranscribeHandler struct { - jobRepo contract.TranscriptJobRepository + recordRepo contract.AudioRecordRepository + textRepo contract.TextRepository trsService *service.TranscribeService logger *slog.Logger } -func NewTranscribeHandler(jobRepo contract.TranscriptJobRepository, trsService *service.TranscribeService, logger *slog.Logger) *TranscribeHandler { +func NewTranscribeHandler( + recordRepo contract.AudioRecordRepository, + textRepo contract.TextRepository, + trsService *service.TranscribeService, + logger *slog.Logger, +) *TranscribeHandler { if logger == nil { logger = slog.Default() } - return &TranscribeHandler{jobRepo: jobRepo, trsService: trsService, logger: logger} + return &TranscribeHandler{recordRepo: recordRepo, textRepo: textRepo, trsService: trsService, logger: logger} } type CreateTranscribeJobResponse struct { @@ -35,16 +41,27 @@ type CreateTranscribeJobResponse struct { State string `json:"status"` } +// GetTranscribeJobResponse — ответ об одной записи. +// +// Имена полей нормативны и остались прежними: контракт HTTP API объявлен +// проектом необратимым, и переименование поля ломает внешнюю программу молча. +// Изменились **значения** поля состояния — рубеж теперь называет достигнутое, — и +// это объявленная ломка. +// +// Поле `halted` новое: отказ перестал быть состоянием, и без него остановленная +// запись выглядела бы как обычная, стоящая на своём рубеже. Машинный текст +// отказа в ответ не идёт: он принадлежит журналу владельца сервиса. type GetTranscribeJobResponse struct { JobID string `json:"job_id"` State string `json:"status"` + Halted bool `json:"halted"` CreatedAt time.Time `json:"created_at"` TranscriptionText *string `json:"transcription_text,omitempty"` } // Register вешает маршруты сервиса на роутер хранилища. Порт у сервиса и у // панели один, поэтому и роутер один; имена полей ответа и коды при переезде -// сохранены — публичный контракт API объявлен необратимым. +// сохранены — публичный контракт HTTP API объявлен необратимым. func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) { api := r.Group("/api") @@ -80,18 +97,18 @@ func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error { } }() - // Запись доехала целиком, поэтому задача заводится независимо от того, - // дождётся ли отправитель ответа: на контексте запроса приём терял бы - // полностью загруженную запись от одного обрыва соединения, а забрать - // результат он может и позже — по `GET /status/{id}`. Значения контекста - // (журнал запроса, сессия) при этом сохраняются, теряется только отмена. + // Запись доехала целиком, поэтому она заводится независимо от того, дождётся + // ли отправитель ответа: на контексте запроса приём терял бы полностью + // загруженную запись от одного обрыва соединения, а забрать результат он + // может и позже — по `GET /status/{id}`. Значения контекста (журнал запроса, + // сессия) при этом сохраняются, теряется только отмена. ctx := context.WithoutCancel(e.Request.Context()) // Владелец берётся из предъявленной сессии и ниоткуда больше: владелец, // пришедший полем запроса, дал бы всякому вошедшему право завести запись на // чужое имя. Проверка предъявителя стоит слоем выше, поэтому здесь `e.Auth` // уже есть и принадлежит коллекции пользователей. - job, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, e.Auth.Id) + record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, e.Auth.Id) if err != nil { // Второй раз отказ не логируем: приём назван конвенцией логирующей // границей и уже написал о нём. Транспорт переводит ошибку в ответ. @@ -100,37 +117,53 @@ func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error { // Возвращаем успешный ответ return e.JSON(http.StatusCreated, CreateTranscribeJobResponse{ - JobID: job.Id, - State: job.State, + JobID: record.Id, + State: record.State, }) } func (h *TranscribeHandler) GetTranscribeJobStatus(e *core.RequestEvent) error { - jobID := e.Request.PathValue("id") + recordID := e.Request.PathValue("id") - // Чужая задача, задача без владельца и несуществующая отвечают одним и тем + // Чужая запись, запись без владельца и несуществующая отвечают одним и тем // же: хранилище отдаёт на все три ту же ошибку, а транспорт — тот же код и // то же тело. Различать их наружу нельзя — по разнице ответов перебирается - // список заведённых задач. - job, err := h.jobRepo.GetByID(jobID, e.Auth.Id) + // список заведённых записей. + record, err := h.recordRepo.GetByID(recordID, e.Auth.Id) if err != nil { // Наружу ответ один на все исходы, а в журнал они идут по-разному. - // «Задачи нет» и «задача чужая» — штатная работа разграничения, о ней + // «Записи нет» и «запись чужая» — штатная работа разграничения, о ней // писать нечего; всё прочее — отказ хранилища, и без этой строки он // приходит отправителю как «вашей записи нет», а владелец сервиса об - // аварии не узнаёт ниоткуда. Журнал читает владелец, а не тот, кто - // перебирает, поэтому различать их здесь можно. + // аварии не узнаёт ниоткуда. var notFound *contract.JobNotFoundError if !errors.As(err, ¬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.StatusOK, GetTranscribeJobResponse{ - JobID: job.Id, - State: job.State, - CreatedAt: job.CreatedAt, - TranscriptionText: job.TranscriptionText, - }) + response := GetTranscribeJobResponse{ + JobID: record.Id, + State: record.State, + Halted: record.IsHalted(), + CreatedAt: record.CreatedAt, + } + + // Вид текста называется **явно**: видов у записи больше одного, и отдача + // «последнего записанного» сделала бы ответ функцией порядка записи, а не + // состояния записи. В это поле уходит сырая расшифровка, и только она. + if record.TranscriptTextID != nil { + text, err := h.textRepo.GetByID(*record.TranscriptTextID) + if err != nil { + h.logger.Error("Failed to read transcript", "error", err, "record_id", recordID) + return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to read transcription"}) + } + if text.Contents != "" { + contents := text.Contents + response.TranscriptionText = &contents + } + } + + return e.JSON(http.StatusOK, response) } diff --git a/internal/controller/http/transcribe_test.go b/internal/controller/http/transcribe_test.go index 1955397..9ccc649 100644 --- a/internal/controller/http/transcribe_test.go +++ b/internal/controller/http/transcribe_test.go @@ -14,6 +14,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" @@ -24,6 +25,7 @@ import ( "git.vakhrushev.me/av/transcriber/internal/adapter/recognizer" pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" + "git.vakhrushev.me/av/transcriber/internal/clock" "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/service" @@ -167,8 +169,16 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv { pbrepo.BindPanelRules(app) - fileRepo := pbrepo.NewFileRepository(app) - jobRepo := pbrepo.NewTranscriptJobRepository(app) + recordRepo := pbrepo.NewAudioRecordRepository(app) + textRepo := pbrepo.NewTextRepository(app) + repos := service.Repositories{ + Records: recordRepo, + Files: pbrepo.NewFileRepository(app), + Texts: textRepo, + Structures: pbrepo.NewStructureRepository(app), + Recognitions: pbrepo.NewRecognitionRepository(app), + Events: pbrepo.NewRecordEventRepository(app), + } // Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на // имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки @@ -178,16 +188,16 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv { logger := slog.New(slog.NewTextHandler(journal, nil)) trsService := service.NewTranscribeService( - jobRepo, - fileRepo, + repos, metaviewer, &stubConverter{}, &recognizer.MemoryAudioRecognizer{}, &TestTgSender{}, + entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour}, logger, ) - handler := NewTranscribeHandler(jobRepo, trsService, logger) + handler := NewTranscribeHandler(recordRepo, textRepo, trsService, logger) // Роутер собирается тем же способом, что и боевой: маршруты вешает сам // обработчик, и проверка судит ту же цепочку, что и прод. @@ -256,16 +266,16 @@ func countFiles(t *testing.T, env *testEnv) int { return len(records) } -// countJobs считает заведённые задачи расшифровки. +// countJobs считает заведённые аудиозаписи. func countJobs(t *testing.T, env *testEnv) int { - records, err := env.app.FindAllRecords(migrations.JobsCollection) + records, err := env.app.FindAllRecords(migrations.RecordsCollection) require.NoError(t, err) return len(records) } // jobWithFile заводит задачу вместе с её записью: ссылка на файл обязательна // схемой, потому что без неё задача не пройдёт ни одного шага. -func jobWithFile(t *testing.T, env *testEnv) *entity.TranscribeJob { +func jobWithFile(t *testing.T, env *testEnv) *entity.AudioRecord { t.Helper() repo := pbrepo.NewFileRepository(env.app) @@ -276,17 +286,18 @@ func jobWithFile(t *testing.T, env *testEnv) *entity.TranscribeJob { // Владелец — учётная запись проверки: задача, пришедшая из веба, без // владельца больше не заводится, и фикстура без него описывала бы состояние, // которого в проде не бывает. - file, err := repo.CreateLocal("sample.mp3", work, env.account.Id) + file, err := repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, env.account.Id) require.NoError(t, err) - job := &entity.TranscribeJob{ - State: entity.StateCreated, - Source: entity.SourceApi, - OwnerID: &env.account.Id, - FileID: &file.Id, + record := &entity.AudioRecord{ + State: entity.StateUploaded, + StateEnteredAt: clock.Now(), + Source: entity.SourceApi, + OwnerID: &env.account.Id, + OriginalFileID: &file.Id, } - require.NoError(t, env.handler.jobRepo.Create(job)) - return job + require.NoError(t, env.handler.recordRepo.Create(record)) + return record } // storedContent читает содержимое файла из хранилища. @@ -327,21 +338,21 @@ func TestCreateTranscribeJob_Success(t *testing.T) { require.NoError(t, err) assert.NotEmpty(t, response.JobID) - assert.Equal(t, entity.StateCreated, response.State) + assert.Equal(t, entity.StateUploaded, response.State) // Задача действительно заведена, а не только названа в ответе: иначе // отправитель получит идентификатор записи, которой не будет никогда. require.Equal(t, 1, countJobs(t, env)) - job, err := env.handler.jobRepo.GetByID(response.JobID, env.account.Id) + job, err := env.handler.recordRepo.GetByID(response.JobID, env.account.Id) require.NoError(t, err) - assert.Equal(t, entity.StateCreated, job.State) - require.NotNil(t, job.FileID) - assert.NotEmpty(t, *job.FileID) + assert.Equal(t, entity.StateUploaded, job.State) + require.NotNil(t, job.OriginalFileID) + assert.NotEmpty(t, *job.OriginalFileID) // Содержимое лежит в хранилище одним файлом и целиком. require.Equal(t, 1, countFiles(t, env)) - assert.Equal(t, content, storedContent(t, env, *job.FileID)) + assert.Equal(t, content, storedContent(t, env, *job.OriginalFileID)) } func TestCreateTranscribeJob_NoFile(t *testing.T) { @@ -404,7 +415,7 @@ func TestCreateTranscribeJob_EmptyFile(t *testing.T) { require.NoError(t, err) assert.NotEmpty(t, response.JobID) - assert.Equal(t, entity.StateCreated, response.State) + assert.Equal(t, entity.StateUploaded, response.State) } func TestCreateTranscribeJob_DifferentFileExtensions(t *testing.T) { @@ -513,7 +524,7 @@ const senderNameMarker = "SENDERNAMELEAKMARKER7Q2" // долг `docs/conventions/logging.md`: `msg` обязан стать короткой категорией. // Когда долг закроют, правка будет здесь и одна. const ( - msgIntake = "Creating transcribe job" + msgIntake = "Creating audio record" // Отказ пишет доменная граница — приём, — а не транспорт: конвенция просит // логировать ошибку один раз, и повторная запись транспорта снята. msgIntakeErr = "Failed to get file info" @@ -603,15 +614,15 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) { var response CreateTranscribeJobResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) - job, err := env.handler.jobRepo.GetByID(response.JobID, env.account.Id) + job, err := env.handler.recordRepo.GetByID(response.JobID, env.account.Id) require.NoError(t, err) - require.NotNil(t, job.FileID) + require.NotNil(t, job.OriginalFileID) // Отбор по идентификатору **этого** прогона: иначе утверждение прошло бы по // строке, оставленной соседней проверкой, и прослеживаемость числилась бы // сохранённой при пустом журнале. journal := env.journal.String() - assert.Contains(t, journal, *job.FileID, "по журналу видно, какой файл заведён") + assert.Contains(t, journal, *job.OriginalFileID, "по журналу видно, какой файл заведён") assert.Contains(t, journal, ".mp3", "расширение принятой записи в журнале остаётся") // Разделитель ключа и значения задаёт обработчик: сегодня текстовый, по @@ -698,7 +709,7 @@ func TestGetTranscribeJobStatus_Success(t *testing.T) { require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) assert.Equal(t, job.Id, response.JobID) - assert.Equal(t, entity.StateCreated, response.State) + assert.Equal(t, entity.StateUploaded, response.State) assert.NotZero(t, response.CreatedAt) } @@ -761,7 +772,7 @@ func TestAcceptedRecordSurvivesSenderDisconnect(t *testing.T) { require.Equal(t, http.StatusCreated, w.Result().StatusCode, "тело ответа: %s", w.Body.String()) - jobs, err := env.app.FindAllRecords(migrations.JobsCollection) + jobs, err := env.app.FindAllRecords(migrations.RecordsCollection) require.NoError(t, err) assert.Len(t, jobs, 1, "задача заведена, несмотря на ушедшего отправителя") } diff --git a/internal/controller/tg/tg.go b/internal/controller/tg/tg.go index c31b5cf..0787b2c 100644 --- a/internal/controller/tg/tg.go +++ b/internal/controller/tg/tg.go @@ -16,7 +16,6 @@ import ( // адаптера» правилом не держится и уже нарушено HTTP-поверхностью // (docs/conventions/go-linters.md, «Что остаётся прозой»). "git.vakhrushev.me/av/transcriber/internal/adapter/telegram" - "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/service" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" ) @@ -24,7 +23,6 @@ import ( type TelegramController struct { // deps transcribeService *service.TranscribeService - jobRepo contract.TranscriptJobRepository logger *slog.Logger // params bot *tgbotapi.BotAPI @@ -44,7 +42,6 @@ func NewTelegramController( config TelegramConfig, bot *tgbotapi.BotAPI, transcribeService *service.TranscribeService, - jobRepo contract.TranscriptJobRepository, logger *slog.Logger, ) (*TelegramController, error) { if bot == nil { @@ -54,7 +51,6 @@ func NewTelegramController( controller := &TelegramController{ bot: bot, transcribeService: transcribeService, - jobRepo: jobRepo, logger: logger, updateTimeout: config.UpdateTimeout, userWhiteList: config.UserWhiteList, @@ -178,16 +174,16 @@ func (c *TelegramController) handleAudioMessage(ctx context.Context, message *tg defer fileReader.Close() // Обрабатываем файл - job, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID) + record, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID) if err != nil { - c.logger.Error("Failed to create transcribe job", "error", err) + c.logger.Error("Failed to create audio record", "error", err) errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.") c.send(errorMsg) return } // Отправляем сообщение об успешном создании задачи - successMsg := tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("Задача на расшифровку создана. ID задачи: %s", job.Id)) + successMsg := tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("Задача на расшифровку создана. ID задачи: %s", record.Id)) successMsg.ReplyToMessageID = message.MessageID c.send(successMsg) } @@ -213,16 +209,16 @@ func (c *TelegramController) handleVoiceMessage(ctx context.Context, message *tg defer fileReader.Close() // Обрабатываем файл - job, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID) + record, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID) if err != nil { - c.logger.Error("Failed to create transcribe job", "error", err) + c.logger.Error("Failed to create audio record", "error", err) errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.") c.send(errorMsg) return } // Отправляем сообщение об успешном создании задачи - successMsg := tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("Задача на расшифровку создана. ID задачи: %s", job.Id)) + successMsg := tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("Задача на расшифровку создана. ID задачи: %s", record.Id)) successMsg.ReplyToMessageID = message.MessageID c.send(successMsg) } @@ -253,16 +249,16 @@ func (c *TelegramController) handleDocumentMessage(ctx context.Context, message defer fileReader.Close() // Обрабатываем файл - job, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID) + record, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID) if err != nil { - c.logger.Error("Failed to create transcribe job", "error", err) + c.logger.Error("Failed to create audio record", "error", err) errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.") c.send(errorMsg) return } // Отправляем сообщение об успешном создании задачи - successMsg := tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("Задача на расшифровку создана. ID задачи: %s", job.Id)) + successMsg := tgbotapi.NewMessage(message.Chat.ID, fmt.Sprintf("Задача на расшифровку создана. ID задачи: %s", record.Id)) successMsg.ReplyToMessageID = message.MessageID c.send(successMsg) } diff --git a/internal/controller/worker/pool_test.go b/internal/controller/worker/pool_test.go new file mode 100644 index 0000000..f1c6354 --- /dev/null +++ b/internal/controller/worker/pool_test.go @@ -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("пул не остановился по отмене контекста") + } +} diff --git a/internal/controller/worker/worker.go b/internal/controller/worker/worker.go index 744a90a..b4f625d 100644 --- a/internal/controller/worker/worker.go +++ b/internal/controller/worker/worker.go @@ -3,26 +3,25 @@ package worker import ( "context" "errors" + "fmt" "log/slog" - "strconv" + "sync" "time" "git.vakhrushev.me/av/transcriber/internal/contract" - "git.vakhrushev.me/av/transcriber/internal/metrics" ) -// Worker представляет базовый интерфейс для всех воркеров -type Worker interface { - Start(ctx context.Context) - Name() string -} - // pollInterval — пауза между прогонами шага. Полем, а не константой по месту: // проверке нужен второй прогон, чтобы остановить воркер **после** того, как он // рассудил об исходе первого. Отменять контекст изнутри шага она не может — // отменённый контекст теперь и значит «нас остановили». const pollInterval = time.Second +// CallbackWorker крутит один и тот же шаг, опрашивая очередь. +// +// Специализации у него нет: шаг сам берёт любую пригодную к работе запись и +// выбирает работу по её рубежу. Раньше воркеров было три именованных, по одному +// на состояние, и каждый новый рубеж требовал четвёртого. type CallbackWorker struct { name string // Шаг принимает контекст воркера: остановка обязана доходить до чужой @@ -64,15 +63,12 @@ func (w *CallbackWorker) Start(ctx context.Context) { // первой же обёртки `%w`, которая в проекте — умолчание. var noop *contract.NoopJobError isNoop := errors.As(err, &noop) - // Остановка — не отказ шага: контекст отменили мы сами. Считать её - // в метрику и писать владельцу «Worker error» значит красить каждую - // выкладку как поломку — по тому же доводу, по которому не считается + // Остановка — не отказ шага: контекст отменили мы сами. Писать + // владельцу «Worker error» значит красить каждую выкладку как + // поломку — по тому же доводу, по которому не считается // `NoopJobError`. Судит контекст, а не текст ошибки: убитый процесс // отдаёт «signal: killed», и `errors.Is` его с отменой не свяжет. stopped := err != nil && !isNoop && ctx.Err() != nil - if !isNoop && !stopped { - metrics.WorkerJobCounter.WithLabelValues(w.Name(), strconv.FormatBool(err != nil)).Inc() - } if err != nil && !isNoop && !stopped { w.logger.Error("Worker error", "worker", w.Name(), "error", err) } @@ -80,6 +76,9 @@ func (w *CallbackWorker) Start(ctx context.Context) { w.logger.Info("Worker step interrupted by shutdown", "worker", w.Name()) } + // Счётчик работы растит сам шаг: только он знает рубеж, с которого + // взята запись, а воркер к рубежу больше не привязан. + // Ждем перед следующей итерацией select { case <-ctx.Done(): @@ -91,3 +90,51 @@ func (w *CallbackWorker) Start(ctx context.Context) { } } } + +// Pool — пул одинаковых воркеров конвейера. +// +// Число задаётся настройкой, и ноль — законное значение: сервис поднимается, +// записи принимаются и не двигаются. Это режим, а не поломка: он нужен местному +// запуску и выкладке, где конвейер надо остановить, не роняя приём. +type Pool struct { + workers []*CallbackWorker + logger *slog.Logger +} + +// NewPool собирает пул из size одинаковых воркеров, крутящих один и тот же шаг. +func NewPool(size int, step func(ctx context.Context) error, logger *slog.Logger) *Pool { + if logger == nil { + logger = slog.Default() + } + + workers := make([]*CallbackWorker, 0, size) + for i := range size { + workers = append(workers, NewCallbackWorker(fmt.Sprintf("pipeline_worker_%d", i+1), step, logger)) + } + + return &Pool{workers: workers, logger: logger} +} + +// Size — сколько воркеров в пуле. +func (p *Pool) Size() int { return len(p.workers) } + +// Start поднимает всех воркеров пула и ждёт их остановки. +func (p *Pool) Start(ctx context.Context) { + if len(p.workers) == 0 { + // Молчать нельзя: пустой пул неотличим от поломки, а объявленный режим + // обязан быть назван. + p.logger.Info("Pipeline workers are disabled by configuration") + return + } + + var wg sync.WaitGroup + for _, w := range p.workers { + wg.Add(1) + go func(worker *CallbackWorker) { + defer wg.Done() + worker.Start(ctx) + p.logger.Info("Worker stopped gracefully", "worker", worker.Name()) + }(w) + } + wg.Wait() +} diff --git a/internal/controller/worker/worker_test.go b/internal/controller/worker/worker_test.go index ce73e53..3cd30ea 100644 --- a/internal/controller/worker/worker_test.go +++ b/internal/controller/worker/worker_test.go @@ -11,14 +11,17 @@ import ( "time" "git.vakhrushev.me/av/transcriber/internal/contract" - "github.com/prometheus/client_golang/prometheus" ) // Проверки этого файла судят одну развилку воркера: пустой прогон против // отказа. Инвариант проекта — «NoopJobError не ошибка» — стоит ровно на ней, а -// цена срабатывания отложенная: три воркера опрашивают базу раз в секунду, и -// пустой прогон, принятый за отказ, даёт три записи в секунду и столько же +// цена срабатывания отложенная: воркеры опрашивают базу раз в секунду, и пустой +// прогон, принятый за отказ, даёт запись в секунду с каждого и столько же // засчитанных сбоев, которых не было. +// +// Счёт работы здесь не судится: он переехал в шаг конвейера вместе с меткой +// рубежа. Воркер к рубежу не привязан и назвать его не может, а метка, +// выведенная из имени потока, перестала что-либо значить с появлением пула. // journalBuffer собирает журнал прогона. Пишут в него из горутины воркера, а // читает проверка — отсюда мьютекс. @@ -113,39 +116,6 @@ func runRecords(journal string) string { return strings.Join(kept, "\n") } -// jobCount читает счётчик работы воркера из общего реестра процесса. Судит -// реестр, а не переменную пакета: метка, потерянная в точке употребления, -// переменную не ломает, а на странице метрик видна. -func jobCount(t *testing.T, worker, errLabel string) float64 { - t.Helper() - - families, err := prometheus.DefaultGatherer.Gather() - if err != nil { - t.Fatalf("не удалось собрать метрики: %v", err) - } - - for _, mf := range families { - if mf.GetName() != "transcriber_worker_job_count" { - continue - } - for _, m := range mf.GetMetric() { - var gotWorker, gotErr string - for _, label := range m.GetLabel() { - switch label.GetName() { - case "name": - gotWorker = label.GetValue() - case "error": - gotErr = label.GetValue() - } - } - if gotWorker == worker && gotErr == errLabel { - return m.GetCounter().GetValue() - } - } - } - return 0 -} - // Обёртка `%w` объявлена конвенцией проекта умолчанием, и до этой задачи первая // же обёртка на пути сломала бы распознавание молча. Оракул держит именно // обёрнутое значение: на голом признак узнавался и приведением типа, то есть @@ -153,11 +123,8 @@ func jobCount(t *testing.T, worker, errLabel string) float64 { func TestWrappedNoopIsNotAFailure(t *testing.T) { const name = "wrapped_noop_worker" - before := jobCount(t, name, "false") - beforeErr := jobCount(t, name, "true") - journal := runOnce(t, name, func(context.Context) error { - return fmt.Errorf("find and acquire job: %w", &contract.NoopJobError{State: "created"}) + return fmt.Errorf("find and acquire record: %w", &contract.NoopJobError{State: "uploaded"}) }) // Записи о старте и остановке воркера законны и к прогону не относятся — @@ -165,21 +132,13 @@ func TestWrappedNoopIsNotAFailure(t *testing.T) { if got := runRecords(journal); got != "" { t.Errorf("пустой прогон попал в журнал: %q", got) } - if got := jobCount(t, name, "false"); got != before { - t.Errorf("счётчик успешных прогонов вырос на пустом прогоне: было %v, стало %v", before, got) - } - if got := jobCount(t, name, "true"); got != beforeErr { - t.Errorf("пустой прогон засчитан отказом: было %v, стало %v", beforeErr, got) - } } -// Без этой проверки оракул был бы зелен и на коде, который не считает отказом -// вообще ничего. -func TestFailureIsLoggedAndCounted(t *testing.T) { +// Без этой проверки оракул был бы зелен и на коде, который не пишет об отказе +// вообще ничего. Счёт отказа судит проверка шага: метку рубежа знает он. +func TestFailureIsLogged(t *testing.T) { const name = "failing_worker" - before := jobCount(t, name, "true") - journal := runOnce(t, name, func(context.Context) error { return errors.New("database is gone") }) @@ -187,42 +146,28 @@ func TestFailureIsLoggedAndCounted(t *testing.T) { if !strings.Contains(journal, "database is gone") { t.Errorf("отказ не виден владельцу: журнал %q", journal) } - if got := jobCount(t, name, "true"); got != before+1 { - t.Errorf("отказ не засчитан: было %v, стало %v", before, got) - } } -// Счёт успешных прогонов — знаменатель доли отказов. Реализация, снявшая его, -// проходит обе проверки выше, а владелец теряет способность отличить «три -// прогона в секунду, все отказали» от «три отказа среди тысячи прогонов». -func TestSuccessIsCounted(t *testing.T) { +// Успешный прогон отказом не записывается. +func TestSuccessIsNotLoggedAsFailure(t *testing.T) { const name = "successful_worker" - before := jobCount(t, name, "false") - journal := runOnce(t, name, func(context.Context) error { return nil }) - if got := jobCount(t, name, "false"); got != before+1 { - t.Errorf("успешный прогон не засчитан: было %v, стало %v", before, got) - } if strings.Contains(journal, "Worker error") { t.Errorf("успешный прогон записан отказом: журнал %q", journal) } } // Остановка сервиса — не отказ шага: контекст отменили мы сами. Без этой -// развилки каждая выкладка красит журнал владельца отказами и накручивает -// счётчик сбоев, которых не было, — тот же довод, по которому не считается -// `NoopJobError`. Судит контекст, а не текст ошибки: убитый по контексту +// развилки каждая выкладка красит журнал владельца отказами, — тот же довод, по +// которому не пишется `NoopJobError`. Судит контекст, а не текст ошибки: убитый по контексту // процесс отдаёт «signal: killed», и `errors.Is` его с отменой не свяжет. func TestShutdownIsNotAFailure(t *testing.T) { const name = "stopped_worker" - beforeErr := jobCount(t, name, "true") - beforeOk := jobCount(t, name, "false") - journal := &journalBuffer{} logger := slog.New(slog.NewTextHandler(journal, nil)) @@ -251,10 +196,4 @@ func TestShutdownIsNotAFailure(t *testing.T) { if got := journal.String(); strings.Contains(got, "Worker error") { t.Errorf("остановка записана отказом: журнал %q", got) } - if got := jobCount(t, name, "true"); got != beforeErr { - t.Errorf("остановка засчитана отказом: было %v, стало %v", beforeErr, got) - } - if got := jobCount(t, name, "false"); got != beforeOk { - t.Errorf("остановка засчитана успешным прогоном: было %v, стало %v", beforeOk, got) - } } diff --git a/internal/entity/audio_record.go b/internal/entity/audio_record.go new file mode 100644 index 0000000..39ab763 --- /dev/null +++ b/internal/entity/audio_record.go @@ -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 +} diff --git a/internal/entity/file.go b/internal/entity/file.go index 47e28a0..de9086e 100644 --- a/internal/entity/file.go +++ b/internal/entity/file.go @@ -21,16 +21,23 @@ const ( // дойдёт до обработчика — без строки в журнале приёма. const MaxRecordSize int64 = 8 << 30 // 8 ГиБ -// File — одна физическая копия: исходник, результат конвертации и копия во -// внешнем хранилище — три разные записи. +// File — одна физическая копия записи. Их ровно две: принятая и приведённая к +// рабочему формату. Копия во внешнем хранилище файлом записи не считается — она +// существует только потому, что провайдер читает аудио по адресу, и её ключ +// живёт в строке попытки распознавания. type File struct { Id string Location string - // FileName — имя, под которым файл лежит: у местной копии это имя, заданное - // сервисом, у внешней — ключ объекта. Своего суффикса хранилище к заданному - // имени не дописывает: суффикс появляется только у имён, которые оно строит - // само из имени отправителя, а это умолчание не применяется. - FileName string - Size int64 - CreatedAt time.Time + // FileName — имя, под которым файл лежит: имя задаёт сервис. Своего суффикса + // хранилище к заданному имени не дописывает: суффикс появляется только у + // имён, которые оно строит само из имени отправителя, а это умолчание не + // применяется. + FileName string + Size int64 + // Format — расширение без точки, приведённое к нижнему регистру. Наружу оно + // выходит только через метку метрики, приведённую к перечню известных. + Format string + // DurationMs — длительность записи, если её удалось прочитать. + DurationMs int64 + CreatedAt time.Time } diff --git a/internal/entity/job.go b/internal/entity/job.go deleted file mode 100644 index 3a34b02..0000000 --- a/internal/entity/job.go +++ /dev/null @@ -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 -} diff --git a/internal/entity/recognition.go b/internal/entity/recognition.go index 6c4614f..9f274d8 100644 --- a/internal/entity/recognition.go +++ b/internal/entity/recognition.go @@ -1,6 +1,8 @@ package entity -// RecognitionStatus представляет статус операции транскрипции +import "time" + +// RecognitionStatus представляет статус операции распознавания у провайдера. type RecognitionStatus int const ( @@ -26,7 +28,7 @@ func (s RecognitionStatus) String() string { } } -// RecognitionResult представляет результат операции транскрипции +// RecognitionResult представляет исход опроса операции распознавания. type RecognitionResult struct { Status RecognitionStatus Error string // Текст ошибки (заполняется при StatusFailed) @@ -76,3 +78,43 @@ func (r *RecognitionResult) GetError() string { } return "" } + +// Recognition — попытка распознавания у внешнего провайдера. +// +// Всё провайдерское живёт здесь, а не колонками записи: идентификатор операции — +// самое провайдерское, что есть в модели, а копия аудио во внешнем хранилище +// существует только потому, что сегодняшний провайдер читает запись по адресу. +// Другой провайдер её не потребует, и смена провайдера не трогает доменную +// сущность вовсе. +// +// Сырой ответ хранится вложением, а не колонкой этой строки: шаг опроса читает +// её раз в несколько секунд, а хранилище читает запись целиком — ответ на +// многочасовую запись ехал бы в память при каждом опросе. Хранится он потому, +// что результат операции у провайдера не переспрашивается. +type Recognition struct { + Id string + RecordID string + Provider string + Model string + // ExternalID — идентификатор операции у провайдера. Заводится **до** + // обращения к нему: окно между ответом провайдера и записью идентификатора — + // то место, где теряется оплаченное. + ExternalID string + // SourceURI — адрес, по которому провайдер читает аудио. + SourceURI string + StartedAt *time.Time + FinishedAt *time.Time +} + +// RecognitionOutcome — доменный результат распознавания, каким его отдаёт +// адаптер. Формата провайдера здесь нет: разбор потока — обязанность адаптера, и +// ни один шаг конвейера не знает, каким потоком и какими полями провайдер +// отвечает. +type RecognitionOutcome struct { + // Replicas — реплики со временем от начала записи. + Replicas []Replica + // PlainText — плоский текст расшифровки. + PlainText string + // Raw — ответ провайдера целиком, как он пришёл, на хранение вложением. + Raw []byte +} diff --git a/internal/entity/record_event.go b/internal/entity/record_event.go new file mode 100644 index 0000000..9b4508a --- /dev/null +++ b/internal/entity/record_event.go @@ -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 +} diff --git a/internal/entity/retired_states.go b/internal/entity/retired_states.go new file mode 100644 index 0000000..5e6ef41 --- /dev/null +++ b/internal/entity/retired_states.go @@ -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" +) diff --git a/internal/entity/stage.go b/internal/entity/stage.go new file mode 100644 index 0000000..226380d --- /dev/null +++ b/internal/entity/stage.go @@ -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 +} diff --git a/internal/entity/structure.go b/internal/entity/structure.go new file mode 100644 index 0000000..ca2df65 --- /dev/null +++ b/internal/entity/structure.go @@ -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 +} diff --git a/internal/entity/text.go b/internal/entity/text.go new file mode 100644 index 0000000..66bfc72 --- /dev/null +++ b/internal/entity/text.go @@ -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 +} diff --git a/internal/entity/topic.go b/internal/entity/topic.go new file mode 100644 index 0000000..72197de --- /dev/null +++ b/internal/entity/topic.go @@ -0,0 +1,22 @@ +package entity + +// MaxTopicsPerRecord — потолок числа тем у одной записи. Без него часовой +// разговор даёт два десятка тем, и словарь распухает за неделю; это же число +// уезжает в запрос к языковой модели. +const MaxTopicsPerRecord = 5 + +// Topic — тема из словаря одного человека. Пара «владелец и название» +// уникальна: словарь тем свой у каждого. +// +// Коллекцией, а не набором строк в записи, потому что перечень тем человека +// нужен целиком перед каждым обращением к модели, а собрать его из наборов строк +// можно только перебором всех его записей. +// +// Ни один шаг этой работы тем не пишет и не читает: место заведено вперёд, чтобы +// задача, считающая темы языковой моделью, не платила вторым необратимым шагом +// схемы. +type Topic struct { + Id string + OwnerID string + Name string +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index c0a485d..ecc1317 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -6,12 +6,17 @@ import ( ) var ( + // Работа конвейера с разрезом по **рубежу**, с которого взята запись, а не по + // имени потока. Воркеры одинаковы, и имя потока перестало что-либо значить; + // а счётчик отказов — единственный сигнал, по которому владелец сервиса + // замечает поломку, и без разреза по шагу «падает приведение» и «падает + // распознавание» стали бы неразличимы. WorkerJobCounter = promauto.NewCounterVec( prometheus.CounterOpts{ Name: "transcriber_worker_job_count", - Help: "Count of jobs handled by each worker", + Help: "Count of pipeline steps by the stage a record was taken from", }, - []string{"name", "error"}, + []string{"stage", "error"}, ) // Размер принятых на обработку файлов (в байтах) diff --git a/internal/service/find_job_test.go b/internal/service/find_job_test.go index cc85aa9..35e2ed2 100644 --- a/internal/service/find_job_test.go +++ b/internal/service/find_job_test.go @@ -5,67 +5,71 @@ import ( "fmt" "log/slog" "testing" - "time" "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" ) // Путь признака «работы нет» состоит из двух звеньев: репозиторий рождает -// «подходящей задачи не нашлось», сервис переводит это в «работы нет», и уже -// его читает воркер. Проверки воркера подменяют работу целиком и второе звено -// не видят — без этого файла правку в сервисе принимал бы только линтер, а он -// судит форму записи, а не то, узнаётся ли признак на самом деле. +// «пригодной записи не нашлось», сервис переводит это в «работы нет», и уже его +// читает воркер. Проверки воркера подменяют работу целиком и второе звено не +// видят — без этого файла правку в сервисе принимал бы только линтер, а он судит +// форму записи, а не то, узнаётся ли признак на самом деле. -// stubJobRepo отдаёт заданную ошибку на запрос задачи. Прочих методов запроса -// задачи проверки этого файла не зовут. -type stubJobRepo struct { +// stubRecordRepo отдаёт заданную ошибку на запрос записи. Прочих методов +// проверки этого файла не зовут. +type stubRecordRepo struct { err error } -func (r *stubJobRepo) Create(*entity.TranscribeJob) error { return nil } -func (r *stubJobRepo) Save(*entity.TranscribeJob, string) error { return nil } +func (r *stubRecordRepo) Create(*entity.AudioRecord) error { return nil } +func (r *stubRecordRepo) Save(*entity.AudioRecord, string) error { return nil } -func (r *stubJobRepo) GetByID(string, string) (*entity.TranscribeJob, error) { +func (r *stubRecordRepo) GetByID(string, string) (*entity.AudioRecord, error) { return nil, errors.New("не зовётся этими проверками") } -func (r *stubJobRepo) FindAndAcquire(string, string, time.Time) (*entity.TranscribeJob, error) { +func (r *stubRecordRepo) Get(string) (*entity.AudioRecord, error) { + return nil, errors.New("не зовётся этими проверками") +} + +func (r *stubRecordRepo) FindAndAcquire([]entity.Stage) (*contract.AcquiredRecord, error) { return nil, r.err } -func serviceWithRepo(repo contract.TranscriptJobRepository) *TranscribeService { - logger := slog.New(slog.DiscardHandler) - return NewTranscribeService(repo, nil, nil, nil, nil, nil, logger) +func serviceWithRepo(repo contract.AudioRecordRepository) *TranscribeService { + return NewTranscribeService( + Repositories{Records: repo}, + nil, nil, nil, nil, + entity.StuckLimits{}, + slog.New(slog.DiscardHandler), + ) } // Репозиторий вправе добавить своему отказу пояснение — соседние ветки того же // метода уже оборачивают ошибки `%w` подряд. Пока признак узнавался приведением // типа, первая такая обёртка превратила бы пустой прогон в отказ: воркер начал -// бы писать в журнал раз в секунду на каждом из трёх воркеров. -func TestFindJobTranslatesWrappedNotFoundToNoop(t *testing.T) { - svc := serviceWithRepo(&stubJobRepo{ - err: fmt.Errorf("find and acquire job: %w", - &contract.JobNotFoundError{State: "created", Message: "appropriate job not found"}), +// бы писать в журнал раз в секунду на каждом воркере пула. +func TestAcquireTranslatesWrappedNotFoundToNoop(t *testing.T) { + svc := serviceWithRepo(&stubRecordRepo{ + err: fmt.Errorf("find and acquire record: %w", + &contract.JobNotFoundError{Message: "no record is ready for work"}), }) - _, _, err := svc.findJob("created", time.Minute) + _, _, err := svc.acquire() var noop *contract.NoopJobError if !errors.As(err, &noop) { - t.Fatalf("обёрнутое «задачи нет» не переведено в пустой прогон: получено %v", err) - } - if noop.State != "created" { - t.Errorf("состояние потеряно при переводе: %q", noop.State) + t.Fatalf("обёрнутое «работы нет» не переведено в пустой прогон: получено %v", err) } } // Оборотная сторона: настоящий отказ хранилища пустым прогоном считаться не -// должен, иначе задача молча крутилась бы в цикле без единой записи. -func TestFindJobKeepsRealFailure(t *testing.T) { - svc := serviceWithRepo(&stubJobRepo{err: errors.New("database is gone")}) +// должен, иначе запись молча крутилась бы в цикле без единой записи в журнале. +func TestAcquireKeepsRealFailure(t *testing.T) { + svc := serviceWithRepo(&stubRecordRepo{err: errors.New("database is gone")}) - _, _, err := svc.findJob("created", time.Minute) + _, _, err := svc.acquire() var noop *contract.NoopJobError if errors.As(err, &noop) { diff --git a/internal/service/metrics_test.go b/internal/service/metrics_test.go new file mode 100644 index 0000000..5d43b69 --- /dev/null +++ b/internal/service/metrics_test.go @@ -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) +} diff --git a/internal/service/ownership_test.go b/internal/service/ownership_test.go index 35be3a4..dc38f05 100644 --- a/internal/service/ownership_test.go +++ b/internal/service/ownership_test.go @@ -3,7 +3,6 @@ package service import ( "strings" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -16,7 +15,7 @@ import ( // показывать, а не кому её считать. Сужение остановило бы расшифровку записей // бота вовсе, а записи остальных поставило бы в зависимость от того, кто первым // завёл учётную запись. -func TestWorkerTakesJobsOfEveryOwner(t *testing.T) { +func TestWorkerTakesRecordsOfEveryOwner(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) first, err := env.service.CreateJobFromApi(t.Context(), @@ -28,42 +27,48 @@ func TestWorkerTakesJobsOfEveryOwner(t *testing.T) { require.NoError(t, err) // Третья пришла ботом, и владельца у неё нет вовсе. - third := newTelegramJob(t, env) + third := newTelegramRecord(t, env) - // Срок протухания в прошлом: захваченная задача остаётся за держателем, и - // следующий вызов берёт следующую, а не ту же самую. + // Захваченная запись остаётся за держателем, и следующий вызов берёт + // следующую, а не ту же самую. taken := map[string]bool{} - for _, holder := range []string{"one", "two", "three"} { - job, err := env.jobRepo.FindAndAcquire(entity.StateCreated, holder, time.Now().Add(-time.Hour)) - require.NoError(t, err, "воркер берёт задачи подряд, владельцем не сужаясь") - taken[job.Id] = true + for range 3 { + acquired, err := env.recordRepo.FindAndAcquire(entity.WorkingStages()) + require.NoError(t, err, "воркер берёт записи подряд, владельцем не сужаясь") + taken[acquired.ID] = true } - assert.True(t, taken[first.Id], "задача первого владельца досталась воркеру") + assert.True(t, taken[first.Id], "запись первого владельца досталась воркеру") assert.True(t, taken[second.Id], "и второго") - assert.True(t, taken[third.Id], "и задача без владельца") + assert.True(t, taken[third.Id], "и запись без владельца") - _, err = env.jobRepo.FindAndAcquire(entity.StateCreated, "next", time.Now().Add(-time.Hour)) + _, err = env.recordRepo.FindAndAcquire(entity.WorkingStages()) var missing *contract.JobNotFoundError - assert.ErrorAs(t, err, &missing, "больше в этом состоянии никого") + assert.ErrorAs(t, err, &missing, "больше пригодных к работе записей нет") } -// Захват читает владельца: снимок задачи, в котором он всегда пуст, был бы -// ловушкой для первого же шага, начавшего сохранять задачу целиком, — и сегодня -// уже ломал бы файл, который шаг заводит. -func TestAcquireCarriesOwner(t *testing.T) { +// Захват отдаёт идентификатор и признак своего захвата — не перечень колонок. +// Колонки шаг читает сам: иначе всякая новая колонка записи попадала бы под +// инвариант проекта о колонках очереди. +func TestAcquireReturnsIdentifierAndHolder(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) owner := newOwner(t, env.app) - job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner) + record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner) require.NoError(t, err) - acquired, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour)) + acquired, err := env.recordRepo.FindAndAcquire(entity.WorkingStages()) require.NoError(t, err) - require.Equal(t, job.Id, acquired.Id) - require.NotNil(t, acquired.OwnerID, "владелец приехал из захвата") - assert.Equal(t, owner, *acquired.OwnerID) + assert.Equal(t, record.Id, acquired.ID, "захват назвал запись") + assert.NotEmpty(t, acquired.Holder, "и признак своего захвата") + + // Колонки читаются отдельным чтением, и владелец среди них. + read := readRecord(t, env, record.Id) + require.NotNil(t, read.OwnerID) + assert.Equal(t, owner, *read.OwnerID) + assert.Equal(t, acquired.Holder, *read.AcquisitionID, "признак захвата записан в саму запись") + require.NotNil(t, read.AcquireExpiresAt, "срок протухания приехал с рубежом") } // Шаг конвейера владельца не затирает: сохранение кладёт только то, чем @@ -72,34 +77,54 @@ func TestPipelineStepKeepsOwner(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) owner := newOwner(t, env.app) - job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner) + record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner) require.NoError(t, err) - // Отказ конвертации — приговор записи: шаг переводит задачу в `failed` и - // сохраняет её. Это сохранение владельца тронуть не должно. - require.NoError(t, env.service.FindAndRunConversionJob(t.Context())) + // Отказ приведения — приговор записи: шаг останавливает её и сохраняет. Это + // сохранение владельца тронуть не должно. + require.NoError(t, env.service.RunStep(t.Context())) - after, err := readJob(env.app, job.Id) - require.NoError(t, err) - require.Equal(t, entity.StateFailed, after.State, "шаг записал свой приговор") + after := readRecord(t, env, record.Id) + require.True(t, after.IsHalted(), "шаг записал свой приговор") require.NotNil(t, after.OwnerID, "владелец пережил шаг") assert.Equal(t, owner, *after.OwnerID) } -// Приём из веба без владельца задачи не заводит. Обязательность держит здесь +// Приём из веба без владельца записи не заводит. Обязательность держит здесь // код, а не схема: колонка допускает пустое значение ради записей бота. func TestCreateJobFromApiRequiresOwner(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) _, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", "") - require.ErrorIs(t, err, contract.ErrOwnerRequired) +} - records, err := env.app.FindAllRecords("transcribe_jobs") +// Чужая запись, ничья и несуществующая отвечают одним и тем же: по разнице +// ответов иначе перебирается список заведённых записей. +func TestGetByIDHidesForeignRecords(t *testing.T) { + env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) + + owner := newOwner(t, env.app) + stranger := newOwner(t, env.app) + + record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner) require.NoError(t, err) - assert.Empty(t, records, "задачи не заведено") + + // Запись без владельца — принятая ботом. + orphan := newTelegramRecord(t, env) + + var missing *contract.JobNotFoundError - files, err := env.app.FindAllRecords("files") - require.NoError(t, err) - assert.Empty(t, files, "и файла тоже: отказ наступает раньше укладки") + _, err = env.recordRepo.GetByID(record.Id, stranger) + require.ErrorAs(t, err, &missing, "чужая запись неотличима от несуществующей") + + _, err = env.recordRepo.GetByID(orphan.Id, stranger) + require.ErrorAs(t, err, &missing, "ничья запись не достаётся никому") + + _, err = env.recordRepo.GetByID(record.Id, "") + require.ErrorAs(t, err, &missing, "пустой владелец не совпадает ни с чем") + + own, err := env.recordRepo.GetByID(record.Id, owner) + require.NoError(t, err, "своя запись отдаётся") + assert.Equal(t, record.Id, own.Id) } diff --git a/internal/service/pipeline_test.go b/internal/service/pipeline_test.go index 35e83be..284d4e0 100644 --- a/internal/service/pipeline_test.go +++ b/internal/service/pipeline_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "time" @@ -24,10 +25,22 @@ import ( "git.vakhrushev.me/av/transcriber/internal/entity" ) -// Проверки конвейера идут против настоящего хранилища: захват, число попыток и -// переход в «мертва» держатся на запросе, и подставной репозиторий проверял бы +// Проверки конвейера идут против настоящего хранилища: захват, число отказов и +// остановка держатся на запросе, и подставной репозиторий проверял бы // собственную заглушку, а не то, что делает база. +// okConverter переливает исходник в результат — так это выглядит у настоящего +// приведения к рабочему формату. +type okConverter struct{} + +func (c *okConverter) Convert(_ context.Context, src, dest string) error { + content, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dest, content, 0o600) +} + // failingConverter отказывает на каждой попытке. type failingConverter struct{} @@ -47,26 +60,50 @@ func (m *failingMetaViewer) GetInfo(context.Context, string) (*contract.AudioInf return nil, errors.New("запись не читается") } -// recordingSender запоминает, что и куда отправлено. +// recordingSender запоминает, что отправлено. type recordingSender struct { + mu sync.Mutex messages []string } func (s *recordingSender) Send(text string, chatId int64, replyMsgId *int) error { + s.mu.Lock() + defer s.mu.Unlock() s.messages = append(s.messages, text) return nil } -type pipelineEnv struct { - app core.App - service *TranscribeService - jobRepo *pbrepo.TranscriptJobRepository - fileRepo *pbrepo.FileRepository - sender *recordingSender +func (s *recordingSender) sent() []string { + s.mu.Lock() + defer s.mu.Unlock() + return append([]string(nil), s.messages...) } +type pipelineEnv struct { + app core.App + service *TranscribeService + repos Repositories + recordRepo *pbrepo.AudioRecordRepository + fileRepo *pbrepo.FileRepository + sender *recordingSender +} + +// testLimits — пределы простоя проверок. Числа боевые; проверка застревания +// двигает не их, а время входа записи в рубеж: сторож меряет именно его. +var testLimits = entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour} + func newPipelineEnv(t *testing.T, metaviewer contract.AudioMetaViewer, converter contract.AudioFileConverter) *pipelineEnv { t.Helper() + return newPipelineEnvWith(t, metaviewer, converter, &recognizer.MemoryAudioRecognizer{}) +} + +func newPipelineEnvWith( + t *testing.T, + metaviewer contract.AudioMetaViewer, + converter contract.AudioFileConverter, + rec contract.AudioRecognizer, +) *pipelineEnv { + t.Helper() app, err := pbrepo.New(t.TempDir()) require.NoError(t, err) @@ -81,158 +118,410 @@ func newPipelineEnv(t *testing.T, metaviewer contract.AudioMetaViewer, converter // нет. pbrepo.BindPanelRules(app) - jobRepo := pbrepo.NewTranscriptJobRepository(app) + recordRepo := pbrepo.NewAudioRecordRepository(app) fileRepo := pbrepo.NewFileRepository(app) + repos := Repositories{ + Records: recordRepo, + Files: fileRepo, + Texts: pbrepo.NewTextRepository(app), + Structures: pbrepo.NewStructureRepository(app), + Recognitions: pbrepo.NewRecognitionRepository(app), + Events: pbrepo.NewRecordEventRepository(app), + } sender := &recordingSender{} - svc := NewTranscribeService( - jobRepo, - fileRepo, - metaviewer, - converter, - &recognizer.MemoryAudioRecognizer{}, - sender, - slog.New(slog.DiscardHandler), - ) + svc := NewTranscribeService(repos, metaviewer, converter, rec, sender, testLimits, slog.New(slog.DiscardHandler)) - return &pipelineEnv{app: app, service: svc, jobRepo: jobRepo, fileRepo: fileRepo, sender: sender} + return &pipelineEnv{ + app: app, + service: svc, + repos: repos, + recordRepo: recordRepo, + fileRepo: fileRepo, + sender: sender, + } } -// newTelegramJob заводит задачу с записью — так, как её завёл бы приём. -func newTelegramJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob { +// newTelegramRecord заводит запись — так, как её завёл бы приём из бота. +func newTelegramRecord(t *testing.T, env *pipelineEnv) *entity.AudioRecord { t.Helper() - chatId := int64(100) - job, err := env.service.CreateJobFromTelegram(t.Context(), strings.NewReader("запись"), "voice.ogg", chatId, 1) + record, err := env.service.CreateJobFromTelegram(t.Context(), strings.NewReader("запись"), "voice.ogg", 100, 1) require.NoError(t, err) - return job + return record } -// clearDelay снимает паузу, чтобы следующий прогон взял задачу сразу: проверка -// судит счётчик попыток, а не то, умеет ли она ждать. -func clearDelay(t *testing.T, env *pipelineEnv, jobID string) { +// clearDelay снимает паузу, чтобы следующий прогон взял запись сразу. +func clearDelay(t *testing.T, env *pipelineEnv, recordID string) { t.Helper() - record, err := env.app.FindRecordById(migrations.JobsCollection, jobID) + record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID) require.NoError(t, err) record.Set("delay_time", "") require.NoError(t, env.app.Save(record)) } -// rotAcquisition отодвигает время захвата так, чтобы он протух: так это -// выглядит, когда шаг оборвался вместе с процессом. -func rotAcquisition(t *testing.T, env *pipelineEnv, jobID string) { +// enteredStateAt отодвигает время входа записи в рубеж: так это выглядит, когда +// запись простояла в нём дольше предела. +func enteredStateAt(t *testing.T, env *pipelineEnv, recordID string, moment time.Time) { t.Helper() - record, err := env.app.FindRecordById(migrations.JobsCollection, jobID) + record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID) require.NoError(t, err) - record.Set("acquire_time", types.NowDateTime().Add(-24*time.Hour)) + record.Set("state_entered_at", types.DateTime{}.Add(0)) + stamp, err := types.ParseDateTime(moment) + require.NoError(t, err) + record.Set("state_entered_at", stamp) require.NoError(t, env.app.Save(record)) } -// Задача, падающая на каждой попытке, уходит в «мертва»: из выборки исчезает, -// видна отбором по состоянию, а отправитель узнаёт о неудаче. Инвариант -// «Принятая запись не теряется молча» допускает два исхода, и молчаливая смерть -// не подходит ни под один. -func TestJobDiesAfterAttemptLimit(t *testing.T) { +// drain крутит конвейер, пока он двигает записи. Паузы опроса снимаются: они +// проверяются отдельно, а здесь мешают дойти до конца. +func drain(t *testing.T, env *pipelineEnv, recordID string) { + t.Helper() + + for range 20 { + clearDelay(t, env, recordID) + err := env.service.RunStep(t.Context()) + var noop *contract.NoopJobError + if errors.As(err, &noop) { + return + } + require.NoError(t, err) + + after := readRecord(t, env, recordID) + if after.State == entity.StateDone || after.IsHalted() { + return + } + } + t.Fatal("конвейер не дошёл до исхода за отведённое число прогонов") +} + +// readRecord читает запись мимо сужения владельцем. +// +// Читающий метод сервиса отдаёт запись только её владельцу, а проверки конвейера +// смотрят записи, принятые ботом: владельца у таких нет вовсе. Проверке нужно не +// право, а состояние записи после шага. +func readRecord(t *testing.T, env *pipelineEnv, id string) *entity.AudioRecord { + t.Helper() + + record, err := env.recordRepo.Get(id) + require.NoError(t, err) + return record +} + +// Запись, отказывающая на каждой попытке, останавливается признаком: из выборки +// исчезает, рубеж сохраняет, а отправитель узнаёт о неудаче. Инвариант «Принятая +// запись не теряется молча» допускает два исхода, и молчаливая остановка не +// подходит ни под один. +func TestRecordHaltsAfterAttemptLimit(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) - job := newTelegramJob(t, env) + record := newTelegramRecord(t, env) - // Отказ конвертации переводит задачу в `failed` сразу, поэтому предел - // попыток проверяем на шаге, который отказывает *не* приговором: подменяем - // его отказом источника метаданных внутри самого шага конвертации нельзя, и - // вместо этого гоняем захват без выполнения шага — так же, как это выглядит - // при гибели процесса. - for i := 0; i < maxAttempts; i++ { - _, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour)) + // Захват без выполнения шага — так это выглядит при гибели процесса: отказа + // шаг объявить не успевает, а попытка засчитана. + for range maxAttempts { + _, err := env.recordRepo.FindAndAcquire(entity.WorkingStages()) require.NoError(t, err) + expireAcquisition(t, env, record.Id) } - rotAcquisition(t, env, job.Id) - // Следующий захват видит перебор и хоронит задачу. - err := env.service.FindAndRunConversionJob(t.Context()) + err := env.service.RunStep(t.Context()) var noop *contract.NoopJobError - require.ErrorAs(t, err, &noop, "мёртвая задача шагу не отдаётся") + require.ErrorAs(t, err, &noop, "остановленная запись шагу не отдаётся") - after, err := readJob(env.app, job.Id) - require.NoError(t, err) - assert.Equal(t, entity.StateDead, after.State, "задача видна отбором по состоянию") - assert.Greater(t, after.Attempts, maxAttempts, "число попыток сохранено") + after := readRecord(t, env, record.Id) + assert.True(t, after.IsHalted(), "запись остановлена признаком") + require.NotNil(t, after.HaltReason) + assert.Equal(t, entity.HaltReasonAttempts, *after.HaltReason) + assert.Equal(t, entity.StateUploaded, after.State, "рубеж пережил остановку") + assert.Greater(t, after.Attempts, maxAttempts, "число отказов сохранено") - require.Len(t, env.sender.messages, 1, "отправитель узнал о неудаче") - assert.Contains(t, env.sender.messages[0], "попытки исчерпаны") + require.Len(t, env.sender.sent(), 1, "отправитель узнал о неудаче") + assert.Contains(t, env.sender.sent()[0], "попытки исчерпаны") // И из выборки она исчезла. - _, err = env.jobRepo.FindAndAcquire(entity.StateCreated, "next", time.Now().Add(time.Hour)) + _, err = env.recordRepo.FindAndAcquire(entity.WorkingStages()) var missing *contract.JobNotFoundError assert.ErrorAs(t, err, &missing) } -// Мёртвая задача возвращается в работу правкой состояния. -func TestDeadJobReturnsAfterStateEdit(t *testing.T) { - env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) +// expireAcquisition отодвигает срок протухания захвата в прошлое: так это +// выглядит, когда шаг оборвался вместе с процессом. +func expireAcquisition(t *testing.T, env *pipelineEnv, recordID string) { + t.Helper() - job := newTelegramJob(t, env) + record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID) + require.NoError(t, err) + record.Set("acquire_expires_at", types.NowDateTime().Add(-time.Hour)) + require.NoError(t, env.app.Save(record)) +} - for i := 0; i < maxAttempts; i++ { - _, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour)) +// Критерий приёмки 1. Остановленная на шаге запись перезапускается снятием +// признака и продолжает с того рубежа, где стояла, — следующим идёт отправка на +// распознавание, а не повторное приведение. +func TestHaltedRecordResumesFromItsStage(t *testing.T) { + env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) + + record := newTelegramRecord(t, env) + + // Доводим до рубежа приведения и останавливаем на нём. + require.NoError(t, env.service.RunStep(t.Context())) + after := readRecord(t, env, record.Id) + require.Equal(t, entity.StateNormalized, after.State) + + after.Halt(entity.HaltReasonStepFailed, "проверочная остановка") + require.NoError(t, env.recordRepo.Save(after, "")) + + // Остановленная запись захвату не выдаётся. + _, err := env.recordRepo.FindAndAcquire(entity.WorkingStages()) + var missing *contract.JobNotFoundError + require.ErrorAs(t, err, &missing, "остановленная запись из выборки исчезла") + + // Снятие признака возвращает её в работу с сохранённого рубежа. + halted := readRecord(t, env, record.Id) + halted.Resume() + require.NoError(t, env.recordRepo.Save(halted, "")) + + resumed := readRecord(t, env, record.Id) + assert.Equal(t, entity.StateNormalized, resumed.State, "рубеж сохранён") + assert.Equal(t, 0, resumed.Attempts, "отказы сброшены") + + // Следующим идёт отправка на распознавание, а не повторное приведение. + require.NoError(t, env.service.RunStep(t.Context())) + next := readRecord(t, env, record.Id) + assert.Equal(t, entity.StateSubmitted, next.State, "продолжили с места остановки") + assert.NotNil(t, next.RecognitionID, "отправка состоялась") +} + +// Критерий приёмки 2. У прошедшей конвейер записи ссылки на исходник и на +// приведённую копию ведут на разные существующие файлы: прежде ссылка была одна, +// и каждый шаг переставлял её на свой результат. +func TestBothFileLinksSurvivePipeline(t *testing.T) { + env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) + + record := newTelegramRecord(t, env) + drain(t, env, record.Id) + + after := readRecord(t, env, record.Id) + require.Equal(t, entity.StateDone, after.State) + + require.NotNil(t, after.OriginalFileID, "ссылка на принятую копию заполнена") + require.NotNil(t, after.NormalizedFileID, "ссылка на приведённую копию заполнена") + assert.NotEqual(t, *after.OriginalFileID, *after.NormalizedFileID, "копии разные") + + for _, id := range []string{*after.OriginalFileID, *after.NormalizedFileID} { + reader, err := env.fileRepo.Open(id) + require.NoError(t, err, "обе копии открываются") + content, err := io.ReadAll(reader) require.NoError(t, err) + assert.NotEmpty(t, content) + require.NoError(t, reader.Close()) } - require.Error(t, env.service.FindAndRunConversionJob(t.Context())) - - record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id) - require.NoError(t, err) - record.Set("state", entity.StateCreated) - require.NoError(t, env.app.Save(record)) - - again, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "next", time.Now().Add(time.Hour)) - require.NoError(t, err, "снятое состояние возвращает задачу в работу") - assert.Equal(t, job.Id, again.Id) } -// Отказ шага не оставляет задачу захваченной до конца срока: захват снимается, -// и задача ждёт нарастающую паузу. Иначе повтор наступал бы через восемь часов. -func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) { - // Источник метаданных отказывает — это отказ шага, а не приговор записи. - env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) +// Критерий приёмки 3. Поведение записи не зависит от числа воркеров: при одном +// и при нескольких она доходит до конечного рубежа. +func TestOutcomeDoesNotDependOnWorkerCount(t *testing.T) { + for _, workers := range []int{1, 4} { + env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) + record := newTelegramRecord(t, env) - job := newTelegramJob(t, env) + for range 20 { + clearDelay(t, env, record.Id) - // Ссылку переставляем на запись без содержимого: шаг отказывает на получении - // рабочей копии — то есть отказом, а не приговором записи. - empty, err := env.fileRepo.CreateRemote("object-key", 1, "") - require.NoError(t, err) + var wg sync.WaitGroup + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + // Исход прогона здесь не судится: воркеров несколько, и всем, + // кроме одного, работы не достаётся. Судится состояние записи. + if err := env.service.RunStep(t.Context()); err != nil { + t.Log(err) + } + }() + } + wg.Wait() - record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id) - require.NoError(t, err) - record.Set("file", empty.Id) - require.NoError(t, env.app.Save(record)) + if readRecord(t, env, record.Id).State == entity.StateDone { + break + } + } - // Первый отказ. - require.Error(t, env.service.FindAndRunConversionJob(t.Context())) - - after, err := readJob(env.app, job.Id) - require.NoError(t, err) - require.Nil(t, after.AcquisitionID, "захват снят: задача пригодна к повтору") - require.NotNil(t, after.DelayTime, "пауза поставлена") - - firstDelay := time.Until(*after.DelayTime) - - // Второй отказ — с той же задачи, пауза снята вручную. - clearDelay(t, env, job.Id) - require.Error(t, env.service.FindAndRunConversionJob(t.Context())) - - after, err = readJob(env.app, job.Id) - require.NoError(t, err) - require.NotNil(t, after.DelayTime) - - secondDelay := time.Until(*after.DelayTime) - assert.Greater(t, secondDelay, firstDelay, "вторая пауза длиннее первой") + after := readRecord(t, env, record.Id) + assert.Equalf(t, entity.StateDone, after.State, "запись дошла до конца при %d воркерах", workers) + assert.Falsef(t, after.IsHalted(), "запись не остановлена при %d воркерах", workers) + } } -// Пауза растёт с числом попыток и упирается в потолок. +// Тот же критерий, вторая половина: нулевое число воркеров — законное значение. +// Записи принимаются и не двигаются, и это режим, а не поломка. +func TestZeroWorkersLeaveRecordUntouched(t *testing.T) { + env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) + record := newTelegramRecord(t, env) + + // Пул нулевого размера ни одного прогона не делает — потому запись остаётся + // там, где её оставил приём. + after := readRecord(t, env, record.Id) + assert.Equal(t, entity.StateUploaded, after.State, "запись принята и стоит на первом рубеже") + assert.False(t, after.IsHalted(), "и не потеряна") + assert.Equal(t, record.Id, after.Id) +} + +// Критерий приёмки 5, первая половина. Откладывание опроса не двигает время +// входа в рубеж и не обнуляет отсчёт застревания: иначе запись, чью чужую +// операцию опрашивают раз в несколько секунд, не достигла бы предела никогда. +func TestPostponeKeepsStuckCountdown(t *testing.T) { + entered := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC) + record := &entity.AudioRecord{State: entity.StateSubmitted, StateEnteredAt: entered} + + for range 100 { + record.Postpone(time.Now().Add(5 * time.Second)) + } + + assert.Equal(t, entered, record.StateEnteredAt, "сотня откладываний не двигает отсчёт") + assert.Equal(t, entity.StateSubmitted, record.State, "и рубежа не трогает") + assert.Nil(t, record.AcquisitionID, "захват при этом снят") + assert.NotNil(t, record.DelayTime, "а пауза поставлена") +} + +// Критерий приёмки 5, вторая половина. Запись, простоявшая в рубеже дольше +// предела, останавливается признаком с причиной «застряла». +func TestStuckRecordIsHalted(t *testing.T) { + env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) + + record := newTelegramRecord(t, env) + enteredStateAt(t, env, record.Id, time.Now().Add(-2*testLimits.Own)) + + err := env.service.RunStep(t.Context()) + var noop *contract.NoopJobError + require.ErrorAs(t, err, &noop, "застрявшая запись шагу не отдаётся") + + after := readRecord(t, env, record.Id) + require.True(t, after.IsHalted()) + require.NotNil(t, after.HaltReason) + assert.Equal(t, entity.HaltReasonStuck, *after.HaltReason, "причина названа") + assert.Equal(t, entity.StateUploaded, after.State, "рубеж сохранён") + + require.Len(t, env.sender.sent(), 1, "отправитель узнал о неудаче") + assert.Contains(t, env.sender.sent()[0], "застряла") +} + +// Конечный рубеж под предел простоя не подпадает: стоять в нём запись будет +// вечно по построению, и сторож остановил бы всякую доведённую запись. +func TestDoneRecordIsNeverStuck(t *testing.T) { + env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) + + record := newTelegramRecord(t, env) + drain(t, env, record.Id) + enteredStateAt(t, env, record.Id, time.Now().Add(-10*24*time.Hour)) + + err := env.service.RunStep(t.Context()) + var noop *contract.NoopJobError + require.ErrorAs(t, err, &noop, "доведённая запись в работу не берётся") + + after := readRecord(t, env, record.Id) + assert.Equal(t, entity.StateDone, after.State) + assert.False(t, after.IsHalted(), "признака остановки у доведённой записи нет") +} + +// Критерий приёмки 6. Держатель захвата отличим значением, а не занятостью +// записи: шаг, чей захват достался другому, результата не пишет и отправителю +// ничего не шлёт. +func TestOnlyHolderWritesResult(t *testing.T) { + env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) + + record := newTelegramRecord(t, env) + + first, err := env.recordRepo.FindAndAcquire(entity.WorkingStages()) + require.NoError(t, err) + require.Equal(t, record.Id, first.ID) + + // Человек снял признак остановки — панель чистит признак захвата, — и запись + // достаётся другому воркеру. + expireAcquisition(t, env, record.Id) + second, err := env.recordRepo.FindAndAcquire(entity.WorkingStages()) + require.NoError(t, err) + require.NotEqual(t, first.Holder, second.Holder, "признак нового захвата отличается") + + // Первый доходит до записи результата и получает отказ. + stale := readRecord(t, env, record.Id) + stale.MoveToState(entity.StateNormalized) + err = env.recordRepo.Save(stale, first.Holder) + + var lost *contract.LostAcquisitionError + require.ErrorAs(t, err, &lost, "потерявший захват не пишет") + + after := readRecord(t, env, record.Id) + assert.Equal(t, entity.StateUploaded, after.State, "рубеж не сдвинут потерявшим захват") + assert.Empty(t, env.sender.sent(), "и отправителю от него ничего не ушло") +} + +// Критерий приёмки 7. Всякий способ вывести запись из работы сообщает +// отправителю: причин остановки больше одной, и обязанность у них общая. +func TestEveryHaltReasonNotifiesSender(t *testing.T) { + reasons := []struct { + name string + halt func(t *testing.T, env *pipelineEnv, recordID string) + }{ + { + name: "приговор шага", + halt: func(t *testing.T, env *pipelineEnv, recordID string) { + require.NoError(t, env.service.RunStep(t.Context())) + }, + }, + { + name: "застревание", + halt: func(t *testing.T, env *pipelineEnv, recordID string) { + enteredStateAt(t, env, recordID, time.Now().Add(-2*testLimits.Own)) + var noop *contract.NoopJobError + require.ErrorAs(t, env.service.RunStep(t.Context()), &noop) + }, + }, + } + + for _, reason := range reasons { + t.Run(reason.name, func(t *testing.T) { + env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) + record := newTelegramRecord(t, env) + + reason.halt(t, env, record.Id) + + after := readRecord(t, env, record.Id) + require.True(t, after.IsHalted(), "запись остановлена") + require.Len(t, env.sender.sent(), 1, "отправитель узнал о неудаче") + }) + } +} + +// Критерий приёмки 8. Повтор шага не создаёт второго приложения: пара «запись и +// вид» уникальна, и вопрос «какой текст отдавать человеку» не становится +// вопросом порядка записи. +func TestRepeatedStoreKeepsSingleText(t *testing.T) { + env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) + + record := newTelegramRecord(t, env) + + first, err := env.repos.Texts.Put(record.Id, entity.TextKindTranscript, "первый разбор") + require.NoError(t, err) + second, err := env.repos.Texts.Put(record.Id, entity.TextKindTranscript, "второй разбор") + require.NoError(t, err) + + assert.Equal(t, first.Id, second.Id, "строка та же, а не вторая") + + stored, err := env.repos.Texts.GetByID(first.Id) + require.NoError(t, err) + assert.Equal(t, "второй разбор", stored.Contents) + + count, err := env.app.CountRecords(migrations.TextsCollection) + require.NoError(t, err) + assert.Equal(t, int64(1), count, "второго комплекта строк не завелось") +} + +// Пауза растёт с числом отказов и упирается в потолок. func TestRetryDelayGrowsAndCaps(t *testing.T) { assert.Equal(t, retryDelayBase, retryDelay(1)) assert.Equal(t, 2*retryDelayBase, retryDelay(2)) @@ -241,6 +530,42 @@ func TestRetryDelayGrowsAndCaps(t *testing.T) { assert.Equal(t, retryDelayBase, retryDelay(0), "нулевая попытка не даёт нулевой паузы") } +// Отказ шага не оставляет запись захваченной до конца срока: захват снимается, +// и запись ждёт нарастающую паузу. Иначе повтор наступал бы через часы. +func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) { + env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) + + record := newTelegramRecord(t, env) + + // Ссылка переставляется на запись о файле без содержимого: шаг отказывает на + // получении рабочей копии — то есть отказом, а не приговором записи. + files, err := env.app.FindCollectionByNameOrId(migrations.FilesCollection) + require.NoError(t, err) + empty := core.NewRecord(files) + empty.Set("location", entity.LocationLocal) + empty.Set("size", 1) + require.NoError(t, env.app.Save(empty)) + + stored, err := env.app.FindRecordById(migrations.RecordsCollection, record.Id) + require.NoError(t, err) + stored.Set("original_file", empty.Id) + require.NoError(t, env.app.Save(stored)) + + require.Error(t, env.service.RunStep(t.Context())) + + after := readRecord(t, env, record.Id) + require.Nil(t, after.AcquisitionID, "захват снят: запись пригодна к повтору") + require.NotNil(t, after.DelayTime, "пауза поставлена") + firstDelay := time.Until(*after.DelayTime) + + clearDelay(t, env, record.Id) + require.Error(t, env.service.RunStep(t.Context())) + + after = readRecord(t, env, record.Id) + require.NotNil(t, after.DelayTime) + assert.Greater(t, time.Until(*after.DelayTime), firstDelay, "вторая пауза длиннее первой") +} + // Рабочая копия убирается на любом исходе, включая отказ. Забытая копия — это // шестичасовая запись во временном каталоге, и узнать о ней неоткуда. func TestWorkFileRemovedAfterIntakeFailure(t *testing.T) { @@ -273,24 +598,43 @@ func TestWorkFileRemovedAfterSuccessfulIntake(t *testing.T) { assert.Empty(t, leftovers, "рабочей копии после успеха не остаётся") } -// Задача не остаётся ссылающейся на файл, которого нет: ссылка переставляется -// только после того, как запись о новом файле существует. -func TestJobNeverPointsToMissingFile(t *testing.T) { +// Уборка проверяется и на шаге приведения: репозиторий даёт единственный способ +// убрать копию, но зовёт его шаг. Копий здесь две — исходник и результат. +func TestWorkFilesRemovedAfterConversionFailure(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("TMPDIR", tempDir) + env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) - job := newTelegramJob(t, env) + newTelegramRecord(t, env) - // Конвертация отказывает — задача уходит в `failed`, но ссылка остаётся на - // исходную запись, а не на несозданный результат. - require.NoError(t, env.service.FindAndRunConversionJob(t.Context())) - - after, err := readJob(env.app, job.Id) + leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*")) require.NoError(t, err) - assert.Equal(t, entity.StateFailed, after.State) - require.NotNil(t, after.FileID) + require.Empty(t, leftovers, "приём убрал свою рабочую копию") - file, err := env.fileRepo.GetByID(*after.FileID) - require.NoError(t, err, "ссылка задачи ведёт на существующую запись о файле") + require.NoError(t, env.service.RunStep(t.Context())) + + leftovers, err = filepath.Glob(filepath.Join(tempDir, "transcriber-*")) + require.NoError(t, err) + assert.Empty(t, leftovers, "ни исходной копии, ни копии под результат не осталось") +} + +// Запись не остаётся ссылающейся на файл, которого нет: ссылка ставится только +// после того, как запись о файле существует. +func TestRecordNeverPointsToMissingFile(t *testing.T) { + env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) + + record := newTelegramRecord(t, env) + + require.NoError(t, env.service.RunStep(t.Context())) + + after := readRecord(t, env, record.Id) + assert.True(t, after.IsHalted(), "приговор шага остановил запись") + require.NotNil(t, after.OriginalFileID) + assert.Nil(t, after.NormalizedFileID, "ссылки на несозданный результат не появилось") + + file, err := env.fileRepo.GetByID(*after.OriginalFileID) + require.NoError(t, err, "ссылка ведёт на существующую запись о файле") assert.NotEmpty(t, file.FileName) } @@ -300,11 +644,11 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) { content := strings.Repeat("запись ", 1000) - job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader(content), "sample.mp3", newOwner(t, env.app)) + record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader(content), "sample.mp3", newOwner(t, env.app)) require.NoError(t, err) - require.NotNil(t, job.FileID) + require.NotNil(t, record.OriginalFileID) - reader, err := env.fileRepo.Open(*job.FileID) + reader, err := env.fileRepo.Open(*record.OriginalFileID) require.NoError(t, err) defer reader.Close() @@ -312,22 +656,22 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) { require.NoError(t, err) assert.Equal(t, content, string(stored)) - // И длина в учёте совпадает с длиной принятого. - file, err := env.fileRepo.GetByID(*job.FileID) + file, err := env.fileRepo.GetByID(*record.OriginalFileID) require.NoError(t, err) assert.Equal(t, int64(len(content)), file.Size) + assert.Equal(t, "mp3", file.Format, "формат копии записан") } -// Рабочая копия хранимого файла отдаётся именем на диске — так её получают -// шаги, отдающие файл внешней программе. +// Рабочая копия хранимого файла отдаётся именем на диске — так её получают шаги, +// отдающие файл внешней программе. func TestLocalizeGivesReadableCopy(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) - job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("содержимое"), "sample.mp3", newOwner(t, env.app)) + record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("содержимое"), "sample.mp3", newOwner(t, env.app)) require.NoError(t, err) - require.NotNil(t, job.FileID) + require.NotNil(t, record.OriginalFileID) - work, err := env.fileRepo.Localize(*job.FileID) + work, err := env.fileRepo.Localize(*record.OriginalFileID) require.NoError(t, err) content, err := os.ReadFile(work.Path()) @@ -339,86 +683,10 @@ func TestLocalizeGivesReadableCopy(t *testing.T) { assert.True(t, os.IsNotExist(err), "закрытая копия убрана") } -// Уборка рабочей копии проверяется и на шаге конвертации: репозиторий даёт -// единственный способ убрать копию, но зовёт его шаг, и норма держится -// проверкой, а не построением. Копий здесь две — исходник и результат. -func TestWorkFilesRemovedAfterConversionFailure(t *testing.T) { - tempDir := t.TempDir() - t.Setenv("TMPDIR", tempDir) - - env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) - - newTelegramJob(t, env) - - // Приём уже отработал — убеждаемся, что за собой он прибрал, иначе остаток - // от него зачёлся бы шагу конвертации. - leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*")) - require.NoError(t, err) - require.Empty(t, leftovers, "приём убрал свою рабочую копию") - - // Конвертация отказывает — задача уходит в `failed`, копии убраны. - require.NoError(t, env.service.FindAndRunConversionJob(t.Context())) - - leftovers, err = filepath.Glob(filepath.Join(tempDir, "transcriber-*")) - require.NoError(t, err) - assert.Empty(t, leftovers, "ни исходной копии, ни копии под результат не осталось") -} - -// readJob читает задачу мимо сужения владельцем. -// -// Читающий метод хранилища отдаёт задачу только её владельцу, а проверки -// конвейера смотрят задачи, принятые ботом: владельца у таких нет вовсе, и по -// правилу разграничения они не достаются никому. Проверке нужен не доступ, а -// состояние записи после шага, поэтому она берёт его прямо из хранилища. Это -// не второй способ читать задачу в сервисе — в сервисе способ по-прежнему один. -func readJob(app core.App, id string) (*entity.TranscribeJob, error) { - record, err := app.FindRecordById(migrations.JobsCollection, id) - if err != nil { - return nil, err - } - - job := &entity.TranscribeJob{ - Id: record.Id, - State: record.GetString("state"), - Source: record.GetString("source"), - Attempts: record.GetInt("attempts"), - CreatedAt: record.GetDateTime("created").Time(), - UpdatedAt: record.GetDateTime("updated").Time(), - } - - for _, field := range []struct { - name string - dst **string - }{ - {"owner", &job.OwnerID}, - {"file", &job.FileID}, - {"error_text", &job.ErrorText}, - {"acquisition_id", &job.AcquisitionID}, - {"recognition_op_id", &job.RecognitionOpID}, - {"transcription_text", &job.TranscriptionText}, - } { - if value := record.GetString(field.name); value != "" { - stored := value - *field.dst = &stored - } - } - - if delay := record.GetDateTime("delay_time"); !delay.IsZero() { - moment := delay.Time() - job.DelayTime = &moment - } - if acquired := record.GetDateTime("acquire_time"); !acquired.IsZero() { - moment := acquired.Time() - job.AcquireTime = &moment - } - - return job, nil -} - // newOwner заводит учётную запись и отдаёт её идентификатор. // // Владелец — связь с коллекцией пользователей, и хранилище проверяет, что такая -// запись есть: выдуманный идентификатор задачу завести не даст. +// запись есть: выдуманный идентификатор запись завести не даст. func newOwner(t *testing.T, app core.App) string { t.Helper() @@ -428,8 +696,77 @@ func newOwner(t *testing.T, app core.App) string { record := core.NewRecord(users) record.Set("email", uuid.NewString()+"@example.test") record.Set("verified", true) - record.SetPassword(uuid.NewString()) + record.Set("password", uuid.NewString()) require.NoError(t, app.Save(record)) return record.Id } + +// Остановка приговором шага засчитывается **отказом**, а не успехом, и пишет в +// журнал записи одну строку, а не две. +// +// Пока исход шага выводился из «шаг не вернул ошибку», остановленная запись +// получала событием `done` вслед за `halted` и растила счётчик успехов — то есть +// единственный канал владельца молчал ровно там, где запись встала. +func TestHaltIsCountedAsFailureAndLoggedOnce(t *testing.T) { + env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) + + beforeOk := stageCount(t, entity.StateUploaded, "false") + beforeErr := stageCount(t, entity.StateUploaded, "true") + + record := newTelegramRecord(t, env) + require.NoError(t, env.service.RunStep(t.Context())) + + after := readRecord(t, env, record.Id) + require.True(t, after.IsHalted(), "приговор шага остановил запись") + + assert.InDelta(t, beforeErr+1, stageCount(t, entity.StateUploaded, "true"), 0, + "остановка засчитана отказом") + assert.InDelta(t, beforeOk, stageCount(t, entity.StateUploaded, "false"), 0, + "и успехом не засчитана") + + events := recordEvents(t, env, record.Id) + require.Len(t, events, 1, "одна строка журнала, а не две") + assert.Equal(t, entity.EventOutcomeFailed, events[0].GetString("outcome"), + "исход назван приговором, а не сделанной работой") +} + +// Откладывание опроса в журнал событий не пишется: часовое ожидание чужой +// операции дало бы там сотни строк ни о чём, и журнал, заведённый для человека, +// стал бы нечитаемым ровно у долгой записи. +func TestPostponeWritesNoEvent(t *testing.T) { + rec := &countingRecognizer{} + rec.inProgress.Store(5) + env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec) + + record := newTelegramRecord(t, env) + require.NoError(t, env.service.RunStep(t.Context())) // приведение + require.NoError(t, env.service.RunStep(t.Context())) // отправка + require.Equal(t, entity.StateSubmitted, readRecord(t, env, record.Id).State) + + before := len(recordEvents(t, env, record.Id)) + + for range 5 { + clearDelay(t, env, record.Id) + require.NoError(t, env.service.RunStep(t.Context())) + } + + assert.Len(t, recordEvents(t, env, record.Id), before, + "пять откладываний не оставили в журнале ни строки") +} + +// recordEvents читает журнал событий одной записи в порядке заведения. +func recordEvents(t *testing.T, env *pipelineEnv, recordID string) []*core.Record { + t.Helper() + + all, err := env.app.FindAllRecords(migrations.RecordEventsCollection) + require.NoError(t, err) + + var own []*core.Record + for _, event := range all { + if event.GetString("record") == recordID { + own = append(own, event) + } + } + return own +} diff --git a/internal/service/recognition_test.go b/internal/service/recognition_test.go index 7ac89f8..6092ff5 100644 --- a/internal/service/recognition_test.go +++ b/internal/service/recognition_test.go @@ -2,267 +2,196 @@ package service import ( "context" + "encoding/json" "errors" "io" - "strings" + "sync/atomic" "testing" - "time" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" - "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" ) -// Шаги распознавания переписаны переездом на новое хранилище целиком: они берут -// содержимое по записи, заводят запись о копии во внешнем хранилище и пишут -// результат условием по держателю захвата. Подставной распознаватель проекта -// умеет только «завершено с фиксированным текстом», поэтому ветки ожидания, -// отказа операции и пустого текста изобразить нечем — для них нужен управляемый -// двойник. - -// scriptedRecognizer отдаёт заданный исход проверки операции и заданный текст. -type scriptedRecognizer struct { - result *entity.RecognitionResult - text string - recognizeErr error - - recognizeCalls int - lastObjectKey string +// countingRecognizer считает обращения наружу: за них платят по факту, и повтор +// оплаченного шага — самый дорогой класс дефекта в этом сервисе. +type countingRecognizer struct { + uploads atomic.Int64 + submits atomic.Int64 + fetches atomic.Int64 + parses atomic.Int64 + objectHere atomic.Bool + inProgress atomic.Int64 } -func (r *scriptedRecognizer) Recognize(_ context.Context, file io.Reader, fileName string) (string, error) { - r.recognizeCalls++ - r.lastObjectKey = fileName - if r.recognizeErr != nil { - return "", r.recognizeErr +func (r *countingRecognizer) Provider() string { return "counting" } + +func (r *countingRecognizer) Model() string { return "counting" } + +func (r *countingRecognizer) Upload(_ context.Context, _ io.Reader, objectKey string) (string, error) { + r.uploads.Add(1) + r.objectHere.Store(true) + return "counting://" + objectKey, nil +} + +func (r *countingRecognizer) ObjectExists(context.Context, string, int64) (bool, error) { + return r.objectHere.Load(), nil +} + +func (r *countingRecognizer) SourceURI(objectKey string) string { + return "counting://" + objectKey +} + +func (r *countingRecognizer) Submit(context.Context, string) (string, error) { + r.submits.Add(1) + return uuid.NewString(), nil +} + +func (r *countingRecognizer) CheckStatus(context.Context, string) (*entity.RecognitionResult, error) { + if r.inProgress.Load() > 0 { + r.inProgress.Add(-1) + return entity.NewInProgressResult(), nil } - // Содержимое обязано быть читаемым: шаг отдаёт его наружу потоком. - if _, err := io.Copy(io.Discard, file); err != nil { - return "", err + return entity.NewCompletedResult(), nil +} + +func (r *countingRecognizer) Fetch(context.Context, string) (*entity.RecognitionOutcome, error) { + r.fetches.Add(1) + return r.Parse(countingPayload(t0Replicas)) +} + +func (r *countingRecognizer) Parse(raw []byte) (*entity.RecognitionOutcome, error) { + r.parses.Add(1) + + var replicas []entity.Replica + if err := json.Unmarshal(raw, &replicas); err != nil { + return nil, errors.New("не разобрать сохранённый ответ") } - return "operation-id", nil + + var plain []byte + for _, replica := range replicas { + if len(plain) > 0 { + plain = append(plain, ' ') + } + plain = append(plain, replica.Text...) + } + + return &entity.RecognitionOutcome{Replicas: replicas, PlainText: string(plain), Raw: raw}, nil } -func (r *scriptedRecognizer) GetRecognitionText(context.Context, string) (string, error) { - return r.text, nil +var t0Replicas = []entity.Replica{ + {StartMs: 0, EndMs: 900, Text: "Первая реплика."}, + {StartMs: 900, EndMs: 1800, Text: "Вторая реплика."}, } -func (r *scriptedRecognizer) CheckRecognitionStatus(context.Context, string) (*entity.RecognitionResult, error) { - return r.result, nil +func countingPayload(replicas []entity.Replica) []byte { + raw, err := json.Marshal(replicas) + if err != nil { + panic(err) + } + return raw } -// convertedJob доводит задачу до состояния, с которого работает шаг -// распознавания: запись принята и сконвертирована. -func convertedJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob { - t.Helper() +// Критерий приёмки 4. Структура реплик строится из сохранённого ответа +// провайдера без единого обращения к нему: результат операции не +// переспрашивается, и архив пересчитывается из сохранённого без рубля. +func TestStructureIsBuiltFromStoredPayload(t *testing.T) { + rec := &countingRecognizer{} + env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec) - job := newTelegramJob(t, env) + record := newTelegramRecord(t, env) + drain(t, env, record.Id) - acquired, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "setup", time.Now().Add(-time.Hour)) + after := readRecord(t, env, record.Id) + require.Equal(t, entity.StateDone, after.State) + require.NotNil(t, after.RecognitionID) + + // Сохранённый ответ на месте и читается целиком. + raw, err := env.repos.Recognitions.ReadRaw(*after.RecognitionID) require.NoError(t, err) - acquired.MoveToState(entity.StateConverted) - require.NoError(t, env.jobRepo.Save(acquired, "setup")) + require.NotEmpty(t, raw, "сырой ответ провайдера сохранён") - return job -} + // А теперь — построение структуры из сохранённого, без обращений наружу. + fetchesBefore := rec.fetches.Load() + submitsBefore := rec.submits.Load() -// withRecognizer пересобирает сервис с управляемым распознавателем поверх того -// же хранилища. -func withRecognizer(env *pipelineEnv, rec contract.AudioRecognizer) *TranscribeService { - return NewTranscribeService( - env.jobRepo, - env.fileRepo, - &okMetaViewer{}, - &failingConverter{}, - rec, - env.sender, - env.service.logger, - ) -} - -// Шаг распознавания отдаёт содержимое наружу, заводит запись о копии во внешнем -// хранилище и переставляет на неё ссылку задачи — только после того, как запись -// о копии существует. -func TestTranscribeJobHandsRecordOverAndMovesOn(t *testing.T) { - env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) - job := convertedJob(t, env) - - rec := &scriptedRecognizer{result: entity.NewInProgressResult()} - svc := withRecognizer(env, rec) - - require.NoError(t, svc.FindAndRunTranscribeJob(t.Context())) - - assert.Equal(t, 1, rec.recognizeCalls, "содержимое отдано распознавателю") - assert.NotEmpty(t, rec.lastObjectKey, "ключ объекта назван") - - after, err := readJob(env.app, job.Id) + outcome, err := env.service.recognizer.Parse(raw) require.NoError(t, err) - assert.Equal(t, entity.StateTranscribe, after.State) - require.NotNil(t, after.RecognitionOpID) - assert.Equal(t, "operation-id", *after.RecognitionOpID) - require.NotNil(t, after.DelayTime, "задержка перед первой проверкой поставлена") + require.Len(t, outcome.Replicas, len(t0Replicas), "структура собрана") + assert.Equal(t, t0Replicas[0].Text, outcome.Replicas[0].Text) + assert.Equal(t, t0Replicas[0].StartMs, outcome.Replicas[0].StartMs, "время реплики сохранено") - // Ссылка задачи ведёт на существующую запись о копии, а не на несозданную. - require.NotNil(t, after.FileID) - copyRecord, err := env.fileRepo.GetByID(*after.FileID) + assert.Equal(t, fetchesBefore, rec.fetches.Load(), "к провайдеру за результатом не ходили") + assert.Equal(t, submitsBefore, rec.submits.Load(), "и новой операции не заводили") + + // Структура записи собрана из того же ответа и лежит своей строкой. + require.NotNil(t, after.StructureID) + structure, err := env.repos.Structures.GetByID(*after.StructureID) require.NoError(t, err) - assert.Equal(t, entity.LocationS3, copyRecord.Location) + assert.Equal(t, entity.StructureVersion, structure.Version) + require.Len(t, structure.Replicas, len(t0Replicas)) + assert.Equal(t, t0Replicas[1].Text, structure.Replicas[1].Text) } -// Отказ распознавателя не двигает задачу: она остаётся пригодной к повтору. -func TestTranscribeJobKeepsJobRetryableOnRecognizerFailure(t *testing.T) { - env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) - job := convertedJob(t, env) +// Шаг с внешней оплатой проверяет сделанное: объект нужного размера на месте — +// заливку не повторяем; операция заведена — не платим второй раз. +func TestPaidWorkIsNotRepeated(t *testing.T) { + rec := &countingRecognizer{} + env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec) - rec := &scriptedRecognizer{recognizeErr: errors.New("распознаватель недоступен")} - svc := withRecognizer(env, rec) + record := newTelegramRecord(t, env) - require.Error(t, svc.FindAndRunTranscribeJob(t.Context())) + // Приведение. + require.NoError(t, env.service.RunStep(t.Context())) + require.Equal(t, entity.StateNormalized, readRecord(t, env, record.Id).State) - after, err := readJob(env.app, job.Id) - require.NoError(t, err) - assert.Equal(t, entity.StateConverted, after.State, "задача осталась на своём шаге") - assert.Nil(t, after.AcquisitionID, "захват снят: задача пригодна к повтору") - assert.NotNil(t, after.DelayTime, "пауза перед повтором поставлена") - assert.Empty(t, env.sender.messages, "отправителю про повторимый отказ не пишут") + // Отправка. + require.NoError(t, env.service.RunStep(t.Context())) + require.Equal(t, int64(1), rec.uploads.Load(), "залили один раз") + require.Equal(t, int64(1), rec.submits.Load(), "и заплатили один раз") + + // Возвращаем запись на рубеж отправки — так это выглядит, когда шаг оборвался + // после оплаты, а захват протух. + after := readRecord(t, env, record.Id) + after.MoveToState(entity.StateNormalized) + require.NoError(t, env.recordRepo.Save(after, "")) + + require.NoError(t, env.service.RunStep(t.Context())) + + assert.Equal(t, int64(1), rec.uploads.Load(), "объект на месте — заливка не повторилась") + assert.Equal(t, int64(1), rec.submits.Load(), "операция заведена — второй раз не платим") } -// transcribingJob доводит задачу до состояния ожидания операции. -func transcribingJob(t *testing.T, env *pipelineEnv, rec contract.AudioRecognizer) *entity.TranscribeJob { - t.Helper() +// Ожидание чужой операции откладывается своей задержкой, отказов не тратит и +// рубежа не двигает. +func TestPollingPostponesWithoutSpendingAttempts(t *testing.T) { + rec := &countingRecognizer{} + rec.inProgress.Store(3) + env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec) - job := convertedJob(t, env) - require.NoError(t, withRecognizer(env, rec).FindAndRunTranscribeJob(t.Context())) + record := newTelegramRecord(t, env) - clearDelay(t, env, job.Id) - return job -} + require.NoError(t, env.service.RunStep(t.Context())) // приведение + require.NoError(t, env.service.RunStep(t.Context())) // отправка + require.Equal(t, entity.StateSubmitted, readRecord(t, env, record.Id).State) -// Ожидание чужой операции попытку не тратит и опрос не учащает: шаг отработал -// без отказа, и задержка у него своя, числом. -func TestCheckJobWaitsWithoutSpendingAttempts(t *testing.T) { - env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) - rec := &scriptedRecognizer{result: entity.NewInProgressResult()} - job := transcribingJob(t, env, rec) + entered := readRecord(t, env, record.Id).StateEnteredAt - svc := withRecognizer(env, rec) + var delays []int64 + for range 3 { + clearDelay(t, env, record.Id) + require.NoError(t, env.service.RunStep(t.Context())) - for i := 0; i < 3; i++ { - require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context())) + after := readRecord(t, env, record.Id) + require.Equal(t, entity.StateSubmitted, after.State, "рубеж не сдвинут откладыванием") + assert.Equal(t, 0, after.Attempts, "ожидание чужой операции отказа не тратит") + assert.WithinDuration(t, entered, after.StateEnteredAt, 0, "и отсчёт застревания не двигает") - after, err := readJob(env.app, job.Id) - require.NoError(t, err) - assert.Equal(t, entity.StateTranscribe, after.State) - assert.Equal(t, 0, after.Attempts, "ожидание операции попытку не тратит") require.NotNil(t, after.DelayTime) - assert.InDelta(t, nextCheckDelay.Seconds(), time.Until(*after.DelayTime).Seconds(), 2, - "задержка опроса не выродилась в наименьшую паузу повтора") - - clearDelay(t, env, job.Id) + delays = append(delays, after.DelayTime.Unix()) } -} -// Отказ операции распознавания — приговор записи: задача уходит в `failed`, а -// отправитель узнаёт причину человеческим текстом. -func TestCheckJobFailsJobAndTellsSender(t *testing.T) { - env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) - rec := &scriptedRecognizer{result: entity.NewInProgressResult()} - job := transcribingJob(t, env, rec) - - rec.result = entity.NewFailedResult("операция отклонена") - svc := withRecognizer(env, rec) - - require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context())) - - after, err := readJob(env.app, job.Id) - require.NoError(t, err) - assert.Equal(t, entity.StateFailed, after.State) - - require.Len(t, env.sender.messages, 1, "отправитель узнал об отказе") - assert.Contains(t, env.sender.messages[0], "сбой при распознавании файла") - assert.NotContains(t, env.sender.messages[0], "операция отклонена", - "машинная причина отправителю не идёт") -} - -// Готовая операция завершает задачу и отдаёт текст отправителю ровно один раз. -func TestCheckJobCompletesAndAnswersOnce(t *testing.T) { - env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) - rec := &scriptedRecognizer{result: entity.NewInProgressResult()} - job := transcribingJob(t, env, rec) - - rec.result = entity.NewCompletedResult() - rec.text = "расшифровка записи" - svc := withRecognizer(env, rec) - - require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context())) - - after, err := readJob(env.app, job.Id) - require.NoError(t, err) - assert.Equal(t, entity.StateDone, after.State) - require.NotNil(t, after.TranscriptionText) - assert.Equal(t, "расшифровка записи", *after.TranscriptionText) - - require.Len(t, env.sender.messages, 1, "отправитель получил ровно один ответ") - assert.Equal(t, "расшифровка записи", env.sender.messages[0]) - - // И задача из выборки исчезла: второй ответ отправителю неоткуда взяться. - _, err = env.jobRepo.FindAndAcquire(entity.StateTranscribe, "next", time.Now().Add(-time.Hour)) - var missing *contract.JobNotFoundError - assert.ErrorAs(t, err, &missing) -} - -// Пустая расшифровка — не отказ: задача завершается, а отправителю уходит -// объяснение вместо пустого сообщения. -func TestCheckJobCompletesEmptyTextWithExplanation(t *testing.T) { - env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) - rec := &scriptedRecognizer{result: entity.NewInProgressResult()} - job := transcribingJob(t, env, rec) - - rec.result = entity.NewCompletedResult() - rec.text = "" - svc := withRecognizer(env, rec) - - require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context())) - - after, err := readJob(env.app, job.Id) - require.NoError(t, err) - assert.Equal(t, entity.StateDone, after.State) - - require.Len(t, env.sender.messages, 1) - assert.Contains(t, strings.ToLower(env.sender.messages[0]), "нет текста") -} - -// Шаг, потерявший захват за время работы, результата не пишет и отправителю не -// отвечает: иначе два воркера пишут в одну задачу, а отправитель получает два -// ответа на одну запись. -func TestCheckJobWritesNothingWhenAcquisitionLost(t *testing.T) { - env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) - rec := &scriptedRecognizer{result: entity.NewInProgressResult()} - job := transcribingJob(t, env, rec) - - rec.result = entity.NewCompletedResult() - rec.text = "расшифровка записи" - - // Захват задачи достался другому, пока шаг работал. - acquired, err := env.jobRepo.FindAndAcquire(entity.StateTranscribe, "mine", time.Now().Add(-time.Hour)) - require.NoError(t, err) - - record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id) - require.NoError(t, err) - record.Set("acquisition_id", "someone-else") - require.NoError(t, env.app.Save(record)) - - svc := withRecognizer(env, rec) - err = svc.checkTranscribeJob(t.Context(), acquired, "mine") - - var lost *contract.LostAcquisitionError - require.ErrorAs(t, err, &lost) - - after, err := readJob(env.app, job.Id) - require.NoError(t, err) - assert.Equal(t, entity.StateTranscribe, after.State, "результат не записан") - assert.Empty(t, env.sender.messages, "отправителю ничего не отправлено") + assert.Len(t, delays, 3, "все три прогона отложили работу") } diff --git a/internal/service/shutdown_test.go b/internal/service/shutdown_test.go index 0ca282c..9948339 100644 --- a/internal/service/shutdown_test.go +++ b/internal/service/shutdown_test.go @@ -8,7 +8,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" ) @@ -28,43 +27,43 @@ func (c *killedConverter) Convert(ctx context.Context, _, _ string) error { return errors.New("ffmpeg conversion failed: signal: killed") } -// Остановка сервиса посреди конвертации не выносит записи приговора: задача -// остаётся пригодной к повтору, попытку не тратит и отправителю о сбое, -// которого не было, не сообщает. Прежде любой отказ `Convert` уводил задачу в -// терминальное `failed`, откуда её возвращает только владелец правкой в панели. -func TestShutdownDuringConversionKeepsJobRetryable(t *testing.T) { +// Критерий приёмки 9. Остановка сервиса посреди приведения не выносит записи +// приговора и **не тратит отказа**: запись не виновата в том, что нас +// перезапустили, и несколько выкладок подряд иначе останавливают здоровую +// многочасовую запись с приговором «попытки исчерпаны». +func TestShutdownDuringConversionKeepsRecordRetryable(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) defer cancel() converter := &killedConverter{cancel: cancel} env := newPipelineEnv(t, &okMetaViewer{}, converter) - job := newTelegramJob(t, env) + record := newTelegramRecord(t, env) - err := env.service.FindAndRunConversionJob(ctx) + err := env.service.RunStep(ctx) require.Error(t, err, "шаг обязан сообщить об обрыве наверх") require.ErrorIs(t, err, context.Canceled, "обрыв узнаётся по смыслу, а не по тексту") - after, err := readJob(env.app, job.Id) - require.NoError(t, err) + after := readRecord(t, env, record.Id) - assert.Equal(t, entity.StateCreated, after.State, "задача осталась на повтор, а не похоронена") - assert.Nil(t, after.AcquisitionID, "захват снят: задачу возьмёт следующий прогон") - assert.Equal(t, 0, after.Attempts, "остановка попытки не тратит") - assert.Nil(t, after.ErrorText, "приговора не выносили") - assert.Empty(t, env.sender.messages, "отправителю о несуществующем сбое не сообщают") + assert.Equal(t, entity.StateUploaded, after.State, "запись осталась на повтор") + assert.False(t, after.IsHalted(), "приговора не выносили") + assert.Nil(t, after.AcquisitionID, "захват снят: запись возьмёт следующий прогон") + assert.Equal(t, 0, after.Attempts, "остановка отказа не тратит") + assert.Nil(t, after.ErrorText) + assert.Empty(t, env.sender.sent(), "отправителю о несуществующем сбое не сообщают") } -// Задача, которую шаг не успел взять, потому что нас уже остановили, остаётся -// нетронутой: захват не случился, попытка не потрачена. -func TestShutdownBeforeStepLeavesJobUntouched(t *testing.T) { +// Запись, которую шаг не успел взять, потому что нас уже остановили, остаётся +// нетронутой: захват не случился, отказ не потрачен. +func TestShutdownBeforeStepLeavesRecordUntouched(t *testing.T) { env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) - job := newTelegramJob(t, env) + record := newTelegramRecord(t, env) ctx, cancel := context.WithCancel(t.Context()) cancel() - err := env.service.FindAndRunConversionJob(ctx) + err := env.service.RunStep(ctx) // Исход «шаг не сделал ничего» — это `NoopJobError`: воркер не пишет о нём // владельцу и не считает его в метрику. @@ -72,12 +71,8 @@ func TestShutdownBeforeStepLeavesJobUntouched(t *testing.T) { var noop *contract.NoopJobError require.ErrorAs(t, err, &noop) - after, err := readJob(env.app, job.Id) - require.NoError(t, err) - assert.Equal(t, entity.StateCreated, after.State) - assert.Equal(t, 0, after.Attempts, "захвата не было — попытке взяться неоткуда") - - record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id) - require.NoError(t, err) - assert.Empty(t, record.GetString("acquisition_id")) + after := readRecord(t, env, record.Id) + assert.Equal(t, entity.StateUploaded, after.State) + assert.Equal(t, 0, after.Attempts, "захвата не было — отказу взяться неоткуда") + assert.Nil(t, after.AcquisitionID) } diff --git a/internal/service/transcribe.go b/internal/service/transcribe.go index 519aeb3..92dd791 100644 --- a/internal/service/transcribe.go +++ b/internal/service/transcribe.go @@ -11,83 +11,141 @@ import ( "strings" "time" - "git.vakhrushev.me/av/transcriber/internal/contract" - "git.vakhrushev.me/av/transcriber/internal/entity" - "git.vakhrushev.me/av/transcriber/internal/metrics" "github.com/google/uuid" "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/metrics" ) const ( defaultAudioExt = "audio" - // Предел попыток. Число обратимо и живёт здесь одним местом; счётчик растёт - // при захвате и обнуляется на шаге, завершившемся без отказа. + // Предел отказов. Число обратимо и живёт здесь одним местом; счётчик растёт + // при захвате и обнуляется на шаге, завершившемся без отказа либо отложившем + // работу. maxAttempts = 5 - // Пауза перед повтором отказавшей задачи растёт с числом попыток до - // потолка. Ожидание чужой операции этой паузой не выражается — у него своя - // задержка числом, и попытки оно не тратит. + // Пауза перед повтором отказавшей записи растёт с числом отказов до потолка. + // Ожидание чужой операции этой паузой не выражается — у него своя задержка + // числом, и отказов оно не тратит. retryDelayBase = time.Second retryDelayCap = 5 * time.Minute - // Сроки захвата. Каждый не меньше того, что его шаг может занять на самом - // длинном допустимом входе: расчётный потолок записи — шесть часов, и - // конвертация такой записи идёт дольше часа по построению. - conversionAcquireTimeout = 8 * time.Hour - transcribeAcquireTimeout = 8 * time.Hour - checkAcquireTimeout = time.Hour - - // Задержки опроса операции распознавания. Числа, а не функция числа попыток: + // Задержки опроса операции распознавания. Числа, а не функция числа отказов: // счётчик на ожидании обнулён, и выведенная из него пауза выродилась бы в // своё наименьшее значение, учащая опрос платного сервиса. firstCheckDelay = 10 * time.Second nextCheckDelay = 5 * time.Second ) +// Имена шагов. Идут меткой в журнал событий записи и в счётчик работы воркера +// вместе с рубежом, с которого запись взята. +const ( + stepNormalize = "normalize" + stepSubmit = "submit" + stepPoll = "poll" + stepFinish = "finish" +) + +// Repositories — хранилища, с которыми работает конвейер. Собраны структурой, а +// не восемью доводами: перечень растёт с моделью, а порядок восьми одинаковых +// указателей в вызове перепутать нечем, кроме внимательности. +type Repositories struct { + Records contract.AudioRecordRepository + Files contract.FileRepository + Texts contract.TextRepository + Structures contract.StructureRepository + Recognitions contract.RecognitionRepository + Events contract.RecordEventRepository +} + type TranscribeService struct { - jobRepo contract.TranscriptJobRepository - fileRepo contract.FileRepository + repos Repositories metaviewer contract.AudioMetaViewer converter contract.AudioFileConverter recognizer contract.AudioRecognizer tgSender contract.TelegramMessageSender + limits entity.StuckLimits logger *slog.Logger } func NewTranscribeService( - jobRepo contract.TranscriptJobRepository, - fileRepo contract.FileRepository, + repos Repositories, metaviewer contract.AudioMetaViewer, converter contract.AudioFileConverter, recognizer contract.AudioRecognizer, tgSender contract.TelegramMessageSender, + limits entity.StuckLimits, logger *slog.Logger, ) *TranscribeService { + if logger == nil { + logger = slog.Default() + } return &TranscribeService{ - jobRepo: jobRepo, - fileRepo: fileRepo, + repos: repos, metaviewer: metaviewer, converter: converter, recognizer: recognizer, tgSender: tgSender, + limits: limits, logger: logger, } } -func (s *TranscribeService) CreateJobFromTelegram(ctx context.Context, file io.Reader, fileName string, chatId int64, replyMsgId int) (*entity.TranscribeJob, error) { - job := &entity.TranscribeJob{ - State: entity.StateCreated, +// stepOutcome — чем кончился шаг. +// +// Исход называется **явно**, а не выводится из «шаг не вернул ошибку»: приговор +// шага и откладывание опроса оба возвращают отсутствие отказа, и по одному лишь +// `nil` они неотличимы от сделанной работы. Пока их различал `nil`, остановленная +// запись писала в журнал `done` вслед за `halted` и растила счётчик успехов, а +// часовое ожидание чужой операции оставляло в журнале сотни строк ни о чём. +type stepOutcome int + +const ( + // outcomeDone — шаг сделал работу и двинул рубеж. + outcomeDone stepOutcome = iota + // outcomePostponed — шаг отложил работу: рубеж прежний, писать нечего. + outcomePostponed + // outcomeHalted — шаг вынес приговор и остановил запись. Строку журнала и + // счётчик отказа ставит сама остановка. + outcomeHalted +) + +// step — шаг конвейера. Держит захват и записывает результат только по нему. +type step func(ctx context.Context, r *entity.AudioRecord, holder string) (stepOutcome, error) + +// stepFor выбирает шаг по рубежу записи — таблицей, а не тем, какой воркер +// пришёл. Воркеры одинаковы и не привязаны к шагу, поэтому решение принимается +// здесь и по одному признаку. +func (s *TranscribeService) stepFor(state string) (string, step, bool) { + switch state { + case entity.StateUploaded: + return stepNormalize, s.normalize, true + case entity.StateNormalized: + return stepSubmit, s.submit, true + case entity.StateSubmitted: + return stepPoll, s.poll, true + case entity.StateTranscribed: + return stepFinish, s.finish, true + default: + return "", nil, false + } +} + +func (s *TranscribeService) CreateJobFromTelegram(ctx context.Context, file io.Reader, fileName string, chatId int64, replyMsgId int) (*entity.AudioRecord, error) { + record := &entity.AudioRecord{ + State: entity.StateUploaded, Source: entity.SourceTelegram, TgChatId: &chatId, TgReplyMessageId: &replyMsgId, } - return s.createTranscribeJob(ctx, job, file, fileName) + return s.createRecord(ctx, record, file, fileName) } -// CreateJobFromApi заводит задачу от имени вошедшего. Владелец обязателен: +// CreateJobFromApi заводит запись от имени вошедшего. Владелец обязателен: // пустой отвергается здесь, потому что колонка владельца допускает пустое // значение ради записей бота, и приём по HTTP — то место, где обязательность // держится. @@ -95,22 +153,22 @@ func (s *TranscribeService) CreateJobFromTelegram(ctx context.Context, file io.R // Отказ этот — последний рубеж, а не первый: предъявителя без учётной записи // пользователя транспорт отвергает раньше, до чтения тела. Здесь он остаётся на // случай нового вызывающего, который такой проверки не поставит. -func (s *TranscribeService) CreateJobFromApi(ctx context.Context, file io.Reader, fileName, ownerID string) (*entity.TranscribeJob, error) { +func (s *TranscribeService) CreateJobFromApi(ctx context.Context, file io.Reader, fileName, ownerID string) (*entity.AudioRecord, error) { if ownerID == "" { - s.logger.Error("Refusing to create job without owner") + s.logger.Error("Refusing to create record without owner") return nil, contract.ErrOwnerRequired } - job := &entity.TranscribeJob{ - State: entity.StateCreated, + record := &entity.AudioRecord{ + State: entity.StateUploaded, Source: entity.SourceApi, OwnerID: &ownerID, } - return s.createTranscribeJob(ctx, job, file, fileName) + return s.createRecord(ctx, record, file, fileName) } -func (s *TranscribeService) createTranscribeJob(ctx context.Context, job *entity.TranscribeJob, file io.Reader, fileName string) (*entity.TranscribeJob, error) { +func (s *TranscribeService) createRecord(ctx context.Context, r *entity.AudioRecord, file io.Reader, fileName string) (*entity.AudioRecord, error) { // Определяем расширение файла ext := filepath.Ext(fileName) if ext == "" { @@ -124,7 +182,7 @@ func (s *TranscribeService) createTranscribeJob(ctx context.Context, job *entity // Содержимое ложится в рабочую копию потоком: в память запись целиком не // читается, расчётный потолок — шесть часов. - work, err := s.fileRepo.Stage(ext, file) + work, err := s.repos.Files.Stage(ext, file) if err != nil { s.logger.Error("Failed to stage uploaded file", "error", err) return nil, err @@ -135,7 +193,7 @@ func (s *TranscribeService) createTranscribeJob(ctx context.Context, job *entity // пишется по инварианту приватности; имя, под которым файл ложится в // хранилище, — потому что оно последняя часть ссылки на скачивание, и // строка журнала вместе с идентификатором записи собрала бы её целиком. - s.logger.Info("Creating transcribe job", "file_ext", ext) + s.logger.Info("Creating audio record", "file_ext", ext) info, err := s.metaviewer.GetInfo(ctx, work.Path()) if err != nil { @@ -149,7 +207,12 @@ func (s *TranscribeService) createTranscribeJob(ctx context.Context, job *entity return nil, err } - fileRecord, err := s.fileRepo.CreateLocal(storageFileName, work, ownerOf(job)) + meta := contract.FileMeta{ + Format: formatOf(ext), + DurationMs: int64(info.Seconds) * 1000, + } + + fileRecord, err := s.repos.Files.Create(storageFileName, work, meta, ownerOf(r)) if err != nil { s.logger.Error("Failed to create file record", "error", err, "file_ext", ext) return nil, err @@ -163,369 +226,638 @@ func (s *TranscribeService) createTranscribeJob(ctx context.Context, job *entity metrics.InputFileDurationHistogram.WithLabelValues().Observe(float64(info.Seconds)) metrics.ObserveInputFileSize(ext, size) - job.FileID = &fileRecord.Id + // Ссылка на принятую копию ставится один раз и больше не переставляется: + // исходник остаётся доступным после того, как запись прошла конвейер. + r.OriginalFileID = &fileRecord.Id + r.StateEnteredAt = clock.Now() - if err := s.jobRepo.Create(job); err != nil { - s.logger.Error("Failed to create job record", "error", err, "file_id", fileRecord.Id) + if err := s.repos.Records.Create(r); err != nil { + s.logger.Error("Failed to create audio record", "error", err, "file_id", fileRecord.Id) return nil, err } - s.logger.Info("Transcribe job created successfully", "job_id", job.Id, "file_id", fileRecord.Id) + s.logger.Info("Audio record created successfully", "record_id", r.Id, "file_id", fileRecord.Id) - return job, nil + return r, nil } -func (s *TranscribeService) FindAndRunConversionJob(ctx context.Context) error { - return s.runStep(ctx, entity.StateCreated, conversionAcquireTimeout, s.convertJob) -} - -func (s *TranscribeService) FindAndRunTranscribeJob(ctx context.Context) error { - return s.runStep(ctx, entity.StateConverted, transcribeAcquireTimeout, s.transcribeJob) -} - -func (s *TranscribeService) FindAndRunTranscribeCheckJob(ctx context.Context) error { - return s.runStep(ctx, entity.StateTranscribe, checkAcquireTimeout, s.checkTranscribeJob) -} - -// runStep забирает задачу и отдаёт её шагу. Отказ шага не оставляет задачу -// захваченной до конца срока: захват снимается, и задача ждёт нарастающую паузу -// — иначе повтор наступал бы через восемь часов, а не через секунду. +// RunStep забирает любую пригодную к работе запись и отдаёт её шагу, выбранному +// по рубежу. +// +// Воркер к шагу не привязан: он не знает заранее, что вытянет, и потому срок +// протухания захвата приезжает с рубежом, а не с ним. +// +// Отказ шага не оставляет запись захваченной до конца срока: захват снимается, и +// запись ждёт нарастающую паузу — иначе повтор наступал бы через часы, а не +// через секунду. // // Контекст доходит до шага, а через него — до внешнего собеседника: остановка // сервиса убивает `ffmpeg` и обрывает запрос к распознаванию. Прерванный шаг -// приговора не выносит: задача остаётся пригодной к повтору, попытки не тратит -// и отправителю о несуществующем сбое не сообщает — исход остановки отличается -// от исхода отказа на каждом шаге. -func (s *TranscribeService) runStep(ctx context.Context, state string, expiration time.Duration, step func(ctx context.Context, job *entity.TranscribeJob, holder string) error) error { - // Нас уже остановили — задачу не забираем: захват стоил бы ей попытки, а - // работы всё равно не будет. Исход «шаг не сделал ничего» — это `NoopJobError` - // по смыслу, и он же не поднимает уровень и не считается в метрику. +// приговора не выносит: запись остаётся пригодной к повтору, отказа не тратит и +// отправителю о несуществующем сбое не сообщает. +func (s *TranscribeService) RunStep(ctx context.Context) error { + // Нас уже остановили — запись не забираем: захват стоил бы ей отказа, а + // работы всё равно не будет. Исход «шаг не сделал ничего» — это + // `NoopJobError` по смыслу, и он же не поднимает уровень и не считается в + // метрику. if ctx.Err() != nil { - return &contract.NoopJobError{State: state} + return &contract.NoopJobError{} } - job, holder, err := s.findJob(state, expiration) + record, holder, err := s.acquire() if err != nil { return err } - if err := step(ctx, job, holder); err != nil { - s.scheduleRetry(job, holder, err) - return err + name, run, ok := s.stepFor(record.State) + if !ok { + // Рубеж, для которого шага нет, — это порча записи либо забытая правка + // таблицы. Молчать нельзя: запись выпала бы из работы без единого следа. + s.logger.Error("No step declared for state", "record_id", record.Id, "state", record.State) + s.halt(record, holder, record.State, entity.HaltReasonStepFailed, + fmt.Sprintf("no step for state %s", record.State), + "сервис не знает, что делать с этой записью") + return &contract.NoopJobError{State: record.State} + } + + // Рубеж запоминается **до** шага: шаг двигает его на свой результат, и метка, + // снятая после, называла бы уже следующий рубеж — «падает приведение» и + // «падает распознавание» перестали бы различаться ровно там, где владелец + // сервиса на это смотрит. + stage := record.State + + started := clock.Start() + outcome, stepErr := run(ctx, record, holder) + duration := time.Since(started) + + stopped := stepErr != nil && ctx.Err() != nil + + if stepErr != nil { + if !stopped { + metrics.WorkerJobCounter.WithLabelValues(stage, labelFailed).Inc() + } + s.scheduleRetry(record, holder, stepErr) + return stepErr + } + + switch outcome { + case outcomeDone: + metrics.WorkerJobCounter.WithLabelValues(stage, labelOk).Inc() + s.appendEvent(record, entity.EventOriginPipeline, name, entity.EventOutcomeDone, "", duration) + case outcomePostponed: + // Откладывание работой не является: рубеж прежний, и строка журнала о + // нём была бы строкой ни о чём — часовое ожидание чужой операции дало бы + // их сотни. Счётчик растёт: прогон состоялся, и его исход — успех. + metrics.WorkerJobCounter.WithLabelValues(stage, labelOk).Inc() + case outcomeHalted: + // Строку журнала и счётчик отказа поставила сама остановка: она знает + // причину, а `RunStep` — только то, что отказа не было. } return nil } -func (s *TranscribeService) convertJob(ctx context.Context, job *entity.TranscribeJob, holder string) error { - s.logger.Info("Starting conversion job", "job_id", job.Id) - - if job.FileID == nil { - s.logger.Error("Job has no file", "job_id", job.Id) - return s.failJob(job, holder, errors.New("job has no file"), "у задачи нет записи") - } - - srcFile, err := s.fileRepo.GetByID(*job.FileID) +// acquire забирает запись, читает её колонки и проверяет обоих сторожей. +// +// Захват отдаёт идентификатор и признак **этого** захвата; колонки читаются +// отдельным чтением. Прежде захват возвращал перечень колонок, и всякая новая +// колонка записи попадала под инвариант проекта о колонках очереди — теперь +// перечня нет вовсе. +func (s *TranscribeService) acquire() (*entity.AudioRecord, string, error) { + acquired, err := s.repos.Records.FindAndAcquire(entity.WorkingStages()) if err != nil { - s.logger.Error("Failed to get source file", "error", err, "file_id", *job.FileID) - return err + // Признак узнаётся по смыслу: репозиторий вправе обернуть свой отказ + // пояснением, и приведение типа от этого сломалось бы молча. + var notFound *contract.JobNotFoundError + if errors.As(err, ¬Found) { + return nil, "", &contract.NoopJobError{} + } + s.logger.Error("Failed to find and acquire a record", "error", err) + return nil, "", fmt.Errorf("failed to find and acquire a record: %w", err) } - // Получаем расширение исходного файла для метрики - srcExt := strings.TrimPrefix(filepath.Ext(srcFile.FileName), ".") - if srcExt == "" { - srcExt = defaultAudioExt - } - - src, err := s.fileRepo.Localize(*job.FileID) + record, err := s.repos.Records.Get(acquired.ID) if err != nil { - s.logger.Error("Failed to localize source file", "error", err, "file_id", *job.FileID) - return err + s.logger.Error("Failed to read acquired record", "error", err, "record_id", acquired.ID) + return nil, "", fmt.Errorf("failed to read acquired record: %w", err) + } + + // Сторож отказов: повторы внутри шага. + if record.Attempts > maxAttempts { + s.logger.Error("Record exhausted its attempts", + "record_id", record.Id, "state", record.State, "attempts", record.Attempts) + s.halt(record, acquired.Holder, record.State, entity.HaltReasonAttempts, + fmt.Sprintf("attempts exhausted: %d", record.Attempts), + "попытки исчерпаны") + return nil, "", &contract.NoopJobError{State: record.State} + } + + // Сторож времени: застревание в рубеже. Считает от входа в рубеж, и + // откладывание опроса его не двигает — иначе запись, чью чужую операцию + // опрашивают раз в несколько секунд, не достигла бы предела никогда. + if s.isStuck(record) { + s.logger.Error("Record is stuck in its state", + "record_id", record.Id, "state", record.State, + "state_entered_at", record.StateEnteredAt) + s.halt(record, acquired.Holder, record.State, entity.HaltReasonStuck, + fmt.Sprintf("stuck in %s", record.State), + "обработка застряла") + return nil, "", &contract.NoopJobError{State: record.State} + } + + return record, acquired.Holder, nil +} + +// isStuck — простояла ли запись в рубеже дольше предела. У конечного рубежа +// предела нет: стоять в нём запись будет вечно по построению. +func (s *TranscribeService) isStuck(r *entity.AudioRecord) bool { + stage, ok := entity.StageByName(r.State) + if !ok { + return false + } + limit, hasLimit := stage.Limit(s.limits) + if !hasLimit || limit <= 0 || r.StateEnteredAt.IsZero() { + return false + } + return clock.Now().Sub(r.StateEnteredAt) > limit +} + +// normalize приводит принятую копию к рабочему формату. +// +// Ссылка на исходник не переставляется: у записи две ссылки, и обе живут до +// конца. +func (s *TranscribeService) normalize(ctx context.Context, r *entity.AudioRecord, holder string) (stepOutcome, error) { + s.logger.Info("Starting normalize step", "record_id", r.Id) + + if r.OriginalFileID == nil { + s.logger.Error("Record has no original file", "record_id", r.Id) + return s.failStep(r, holder, stepNormalize, errors.New("record has no original file"), "у записи нет файла") + } + + srcFile, err := s.repos.Files.GetByID(*r.OriginalFileID) + if err != nil { + s.logger.Error("Failed to get original file", "error", err, "file_id", *r.OriginalFileID) + return outcomeDone, err + } + + srcFormat := srcFile.Format + if srcFormat == "" { + srcFormat = defaultAudioExt + } + + src, err := s.repos.Files.Localize(*r.OriginalFileID) + if err != nil { + s.logger.Error("Failed to localize original file", "error", err, "file_id", *r.OriginalFileID) + return outcomeDone, err } defer s.closeWork(src) - dest, err := s.fileRepo.StageEmpty(".ogg") + dest, err := s.repos.Files.StageEmpty(".ogg") if err != nil { - s.logger.Error("Failed to stage converted file", "error", err, "job_id", job.Id) - return err + s.logger.Error("Failed to stage normalized file", "error", err, "record_id", r.Id) + return outcomeDone, err } defer s.closeWork(dest) - s.logger.Info("Converting file", "job_id", job.Id, "src_format", srcExt) + s.logger.Info("Converting file", "record_id", r.Id, "src_format", srcFormat) - // Измеряем время конвертации startTime := clock.Start() err = s.converter.Convert(ctx, src.Path(), dest.Path()) conversionDuration := time.Since(startTime) - // Записываем метрику времени конвертации - metrics.ObserveConversionDuration(srcExt, "ogg", err != nil, conversionDuration.Seconds()) + metrics.ObserveConversionDuration(srcFormat, "ogg", err != nil, conversionDuration.Seconds()) if err != nil { // Остановка сервиса — не приговор записи. Убитый по контексту `ffmpeg` // отдаёт `signal: killed`, и от настоящего отказа конвертации // (`exit status N`) эта ошибка неотличима ни типом, ни `errors.Is`: // различает их только контекст шага. Без этой развилки каждый деплой - // хоронил бы конвертируемую запись в `failed` — состояние терминальное, - // и вернуть её оттуда может только владелец правкой в панели, — да ещё - // и сообщал бы отправителю о сбое, которого не было. + // останавливал бы конвертируемую запись. if ctxErr := ctx.Err(); ctxErr != nil { s.logger.Info("File conversion interrupted by shutdown", - "job_id", job.Id, - "duration", conversionDuration) - return fmt.Errorf("conversion interrupted: %w", ctxErr) + "record_id", r.Id, "duration", conversionDuration) + return outcomeDone, fmt.Errorf("conversion interrupted: %w", ctxErr) } s.logger.Error("File conversion failed", - "error", err, - "job_id", job.Id, - "duration", conversionDuration) - return s.failJob(job, holder, err, "сбой конвертации файла") + "error", err, "record_id", r.Id, "duration", conversionDuration) + return s.failStep(r, holder, stepNormalize, err, "сбой конвертации файла") } destSize, err := dest.Size() if err != nil { - s.logger.Error("Failed to measure converted file", "error", err, "job_id", job.Id) - return err + s.logger.Error("Failed to measure normalized file", "error", err, "record_id", r.Id) + return outcomeDone, err } s.logger.Info("File conversion completed", - "job_id", job.Id, - "duration", conversionDuration, - "output_size", destSize) + "record_id", r.Id, "duration", conversionDuration, "output_size", destSize) - // Записываем метрику размера выходного файла metrics.OutputFileSizeHistogram.WithLabelValues("ogg").Observe(float64(destSize)) destFileName := fmt.Sprintf("%s%s", uuid.NewString(), ".ogg") - destFileRecord, err := s.fileRepo.CreateLocal(destFileName, dest, ownerOf(job)) + destMeta := contract.FileMeta{Format: "ogg", DurationMs: srcFile.DurationMs} + destFileRecord, err := s.repos.Files.Create(destFileName, dest, destMeta, ownerOf(r)) if err != nil { - s.logger.Error("Failed to create converted file record", "error", err, "job_id", job.Id) - return err + s.logger.Error("Failed to create normalized file record", "error", err, "record_id", r.Id) + return outcomeDone, err } - // Ссылка переставляется только после того, как запись о новом файле есть: - // иначе повтор оставил бы задачу указывающей на файл, которого нет. - job.FileID = &destFileRecord.Id - job.MoveToState(entity.StateConverted) + // Ссылка ставится только после того, как запись о файле есть: иначе повтор + // оставил бы запись указывающей на файл, которого нет. + r.NormalizedFileID = &destFileRecord.Id + r.MoveToState(entity.StateNormalized) - if err := s.jobRepo.Save(job, holder); err != nil { - s.logger.Error("Failed to save job", "error", err, "job_id", job.Id) - return err + if err := s.repos.Records.Save(r, holder); err != nil { + s.logger.Error("Failed to save record", "error", err, "record_id", r.Id) + return outcomeDone, err } - s.logger.Info("Conversion job completed successfully", "job_id", job.Id) - return nil + s.logger.Info("Normalize step completed successfully", "record_id", r.Id) + return outcomeDone, nil } -func (s *TranscribeService) transcribeJob(ctx context.Context, job *entity.TranscribeJob, holder string) error { - s.logger.Info("Starting transcribe job", "job_id", job.Id) +// submit кладёт аудио туда, откуда провайдер его прочитает, и заводит операцию +// распознавания. +// +// Заливка и отправка разделены: повтор заливки бесплатен, повтор отправки +// оплачивается наружу. Строка попытки заводится **до** обращения к провайдеру — +// окно между его ответом и записью идентификатора это то место, где теряется +// оплаченное. +func (s *TranscribeService) submit(ctx context.Context, r *entity.AudioRecord, holder string) (stepOutcome, error) { + s.logger.Info("Starting submit step", "record_id", r.Id) - if job.FileID == nil { - s.logger.Error("Job has no file", "job_id", job.Id) - return s.failJob(job, holder, errors.New("job has no file"), "у задачи нет записи") + if r.NormalizedFileID == nil { + s.logger.Error("Record has no normalized file", "record_id", r.Id) + return s.failStep(r, holder, stepSubmit, errors.New("record has no normalized file"), "у записи нет приведённого файла") } - fileRecord, err := s.fileRepo.GetByID(*job.FileID) + fileRecord, err := s.repos.Files.GetByID(*r.NormalizedFileID) if err != nil { - s.logger.Error("Failed to get file record", "error", err, "file_id", *job.FileID) - return err + s.logger.Error("Failed to get normalized file", "error", err, "file_id", *r.NormalizedFileID) + return outcomeDone, err } - content, err := s.fileRepo.Open(*job.FileID) + attempt, err := s.recognitionAttempt(r, holder) if err != nil { - s.logger.Error("Failed to open file", "error", err, "file_id", *job.FileID) - return err + return outcomeDone, err + } + + // Работа уже сделана — второй раз наружу не платим. + if attempt.ExternalID != "" { + s.logger.Info("Recognition operation already submitted", "record_id", r.Id) + return s.awaitOperation(r, holder) + } + + sourceURI, err := s.uploadSource(ctx, r, attempt, fileRecord) + if err != nil { + return outcomeDone, err + } + + operationID, err := s.recognizer.Submit(ctx, sourceURI) + if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + s.logger.Info("Recognition submit interrupted by shutdown", "record_id", r.Id) + return outcomeDone, fmt.Errorf("recognition submit interrupted: %w", ctxErr) + } + s.logger.Error("Failed to submit recognition", "error", err, "record_id", r.Id) + return outcomeDone, err + } + + if err := s.repos.Recognitions.Submitted(attempt.Id, sourceURI, operationID); err != nil { + // Идентификатор операции получен, а записать его не вышло: следующая + // попытка оплатит ту же запись второй раз. Уровень здесь владельцу + // сервиса, а не отправителю. + s.logger.Error("Failed to store operation id", "error", err, "record_id", r.Id) + return outcomeDone, err + } + + s.logger.Info("Recognition submitted", "record_id", r.Id, "operation_id", operationID) + + return s.awaitOperation(r, holder) +} + +// awaitOperation двигает запись на рубеж ожидания и назначает первую задержку +// опроса. +func (s *TranscribeService) awaitOperation(r *entity.AudioRecord, holder string) (stepOutcome, error) { + r.MoveToState(entity.StateSubmitted) + r.DelayTime = ptr(clock.Now().Add(firstCheckDelay)) + + if err := s.repos.Records.Save(r, holder); err != nil { + s.logger.Error("Failed to save record", "error", err, "record_id", r.Id) + return outcomeDone, err + } + return outcomeDone, nil +} + +// recognitionAttempt возвращает строку попытки распознавания, заводя её при +// первом проходе. +// +// Строка заводится **до** обращения к провайдеру и сохраняется на записи тем же +// движением: без этого прерванный между заведением и сохранением шаг завёл бы на +// повторе вторую попытку, и первая осталась бы сиротой — а по ней он и узнаёт, +// что за запись уже заплачено. +func (s *TranscribeService) recognitionAttempt(r *entity.AudioRecord, holder string) (*entity.Recognition, error) { + if r.RecognitionID != nil { + attempt, err := s.repos.Recognitions.GetByID(*r.RecognitionID) + if err != nil { + s.logger.Error("Failed to read recognition attempt", "error", err, "record_id", r.Id) + return nil, err + } + return attempt, nil + } + + attempt := &entity.Recognition{ + RecordID: r.Id, + Provider: s.recognizer.Provider(), + Model: s.recognizer.Model(), + } + if err := s.repos.Recognitions.Create(attempt); err != nil { + s.logger.Error("Failed to create recognition attempt", "error", err, "record_id", r.Id) + return nil, err + } + + r.RecognitionID = &attempt.Id + if err := s.repos.Records.Save(r, holder); err != nil { + s.logger.Error("Failed to link recognition attempt", "error", err, "record_id", r.Id) + return nil, err + } + + return attempt, nil +} + +// uploadSource кладёт приведённую копию туда, откуда провайдер её прочитает, — +// если её там ещё нет, — и отдаёт адрес. +// +// Повтор заливки бесплатен, но дорог по времени на многочасовой записи, поэтому +// шаг сперва смотрит на наблюдаемый признак сделанного. +// +// Адрес уже уложенного берётся из **строки попытки**, а не пересчитывается: он +// сохранён тем шагом, который заливал, и пересчёт разошёлся бы с сохранённым +// молча при первой же смене правила именования объекта или адреса хранилища. +func (s *TranscribeService) uploadSource( + ctx context.Context, + r *entity.AudioRecord, + attempt *entity.Recognition, + file *entity.File, +) (string, error) { + objectKey := file.FileName + + exists, err := s.recognizer.ObjectExists(ctx, objectKey, file.Size) + if err != nil { + s.logger.Error("Failed to check uploaded object", "error", err, "record_id", r.Id) + return "", err + } + + if exists && attempt.SourceURI != "" { + s.logger.Info("Audio is already uploaded, skipping", "record_id", r.Id) + return attempt.SourceURI, nil + } + + content, err := s.repos.Files.Open(*r.NormalizedFileID) + if err != nil { + s.logger.Error("Failed to open normalized file", "error", err, "file_id", *r.NormalizedFileID) + return "", err } defer func() { if err := content.Close(); err != nil { - s.logger.Error("Failed to close file", "error", err, "file_id", *job.FileID) + s.logger.Error("Failed to close file", "error", err, "file_id", *r.NormalizedFileID) } }() - s.logger.Info("Starting recognition", "job_id", job.Id, "file_id", *job.FileID) - - // Запускаем асинхронное распознавание - operationID, err := s.recognizer.Recognize(ctx, content, fileRecord.FileName) + sourceURI, err := s.recognizer.Upload(ctx, content, objectKey) if err != nil { if ctxErr := ctx.Err(); ctxErr != nil { - s.logger.Info("Recognition interrupted by shutdown", "job_id", job.Id) - return fmt.Errorf("recognition interrupted: %w", ctxErr) + s.logger.Info("Upload interrupted by shutdown", "record_id", r.Id) + return "", fmt.Errorf("upload interrupted: %w", ctxErr) } - s.logger.Error("Failed to start recognition", "error", err, "job_id", job.Id) - return err + s.logger.Error("Failed to upload audio for recognition", "error", err, "record_id", r.Id) + return "", err } - s.logger.Info("Recognition started", - "job_id", job.Id, - "operation_id", operationID) + return sourceURI, nil +} - destFileRecord, err := s.fileRepo.CreateRemote(fileRecord.FileName, fileRecord.Size, ownerOf(job)) +// poll опрашивает операцию у провайдера. +// +// Операция ещё идёт — работа **откладывается**, а не переводится в тот же рубеж: +// переходом это никогда не было, и именно мнимость перехода прежде обнуляла +// сторожа времени. +func (s *TranscribeService) poll(ctx context.Context, r *entity.AudioRecord, holder string) (stepOutcome, error) { + if r.RecognitionID == nil { + s.logger.Error("Record has no recognition attempt", "record_id", r.Id) + return s.failStep(r, holder, stepPoll, errors.New("record has no recognition attempt"), "сведений о распознавании нет") + } + + attempt, err := s.repos.Recognitions.GetByID(*r.RecognitionID) if err != nil { - s.logger.Error("Failed to create S3 file record", "error", err, "job_id", job.Id) - return err + s.logger.Error("Failed to read recognition attempt", "error", err, "record_id", r.Id) + return outcomeDone, err + } + if attempt.ExternalID == "" { + s.logger.Error("Recognition attempt has no operation id", "record_id", r.Id) + return s.failStep(r, holder, stepPoll, errors.New("recognition attempt has no operation id"), "сведений о распознавании нет") } - // Обновляем задачу с ID операции распознавания - job.FileID = &destFileRecord.Id - job.RecognitionOpID = &operationID - delayTime := clock.Now().Add(firstCheckDelay) - job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime) - - if err := s.jobRepo.Save(job, holder); err != nil { - s.logger.Error("Failed to save job", "error", err, "job_id", job.Id) - return err + // Опрос идёт раз в несколько секунд всё время распознавания: часовая запись + // дала бы полторы тысячи строк. Конвенция журнала относит проверку готовности + // операции к отладочному уровню поимённо. + s.logger.Debug("Checking operation status", "record_id", r.Id, "operation_id", attempt.ExternalID) + result, err := s.recognizer.CheckStatus(ctx, attempt.ExternalID) + if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + s.logger.Info("Status check interrupted by shutdown", "record_id", r.Id) + return outcomeDone, fmt.Errorf("status check interrupted: %w", ctxErr) + } + s.logger.Error("Failed to check recognition status", "error", err, "operation_id", attempt.ExternalID) + return outcomeDone, err } - s.logger.Info("Transcribe job updated successfully", "job_id", job.Id) + if result.IsInProgress() { + // Шаг отработал без отказа: ожидание чужой операции отказом не является. + // Задержка своя, числом; рубеж и время входа в него не трогаются. + s.logger.Debug("Operation in progress", "record_id", r.Id, "operation_id", attempt.ExternalID) + r.Postpone(clock.Now().Add(nextCheckDelay)) + if err := s.repos.Records.Save(r, holder); err != nil { + s.logger.Error("Failed to save record", "error", err, "record_id", r.Id) + return outcomeDone, err + } + return outcomePostponed, nil + } + + if result.IsFailed() { + errorText := result.GetError() + s.logger.Error("Operation failed", + "record_id", r.Id, "operation_id", attempt.ExternalID, "error_message", errorText) + return s.failStep(r, holder, stepPoll, errors.New(errorText), "сбой при распознавании файла") + } + + outcome, err := s.recognizer.Fetch(ctx, attempt.ExternalID) + if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + s.logger.Info("Result fetch interrupted by shutdown", "record_id", r.Id) + return outcomeDone, fmt.Errorf("result fetch interrupted: %w", ctxErr) + } + s.logger.Error("Failed to fetch recognition result", "error", err, "operation_id", attempt.ExternalID) + return outcomeDone, err + } + + s.logger.Info("Recognition completed", + "record_id", r.Id, + "operation_id", attempt.ExternalID, + "text_length", len(outcome.PlainText), + "replicas", len(outcome.Replicas)) + + // Сырой ответ сохраняется целиком: результат операции у провайдера не + // переспрашивается, и когда мы научимся размечать говорящих, архив + // пересчитается из сохранённого без единого рубля. + if err := s.repos.Recognitions.Finish(attempt.Id, outcome.Raw); err != nil { + s.logger.Error("Failed to store provider payload", "error", err, "record_id", r.Id) + return outcomeDone, err + } + + if err := s.storeOutcome(r, outcome); err != nil { + return outcomeDone, err + } + + r.MoveToState(entity.StateTranscribed) + if err := s.repos.Records.Save(r, holder); err != nil { + s.logger.Error("Failed to save record", "error", err, "record_id", r.Id) + return outcomeDone, err + } + + return outcomeDone, nil +} + +// storeOutcome кладёт расшифровку и структуру реплик. Обе строки уникальны по +// своей паре, поэтому повтор прерванного шага второго комплекта не заводит. +func (s *TranscribeService) storeOutcome(r *entity.AudioRecord, outcome *entity.RecognitionOutcome) error { + text, err := s.repos.Texts.Put(r.Id, entity.TextKindTranscript, outcome.PlainText) + if err != nil { + s.logger.Error("Failed to store transcript", "error", err, "record_id", r.Id) + return err + } + r.TranscriptTextID = &text.Id + + structure, err := s.repos.Structures.Put(r.Id, entity.StructureVersion, outcome.Replicas) + if err != nil { + s.logger.Error("Failed to store structure", "error", err, "record_id", r.Id) + return err + } + r.StructureID = &structure.Id + return nil } -func (s *TranscribeService) checkTranscribeJob(ctx context.Context, job *entity.TranscribeJob, holder string) error { - if job.RecognitionOpID == nil { - s.logger.Error("Recognition operation ID not found", "job_id", job.Id) - return fmt.Errorf("recognition opId not found for job: %s", job.Id) - } - - opId := *job.RecognitionOpID - - // Проверяем статус операции - s.logger.Info("Checking operation status", "job_id", job.Id, "operation_id", opId) - recResult, err := s.recognizer.CheckRecognitionStatus(ctx, opId) - if err != nil { - if ctxErr := ctx.Err(); ctxErr != nil { - s.logger.Info("Status check interrupted by shutdown", "job_id", job.Id) - return fmt.Errorf("status check interrupted: %w", ctxErr) +// finish отвечает отправителю и доводит запись до конечного рубежа. Доставка — +// хвост последнего шага, а не отдельный узел конвейера. +func (s *TranscribeService) finish(ctx context.Context, r *entity.AudioRecord, holder string) (stepOutcome, error) { + text := "Ой, кажется, на аудиозаписи нет текста." + if r.TranscriptTextID != nil { + stored, err := s.repos.Texts.GetByID(*r.TranscriptTextID) + if err != nil { + s.logger.Error("Failed to read transcript", "error", err, "record_id", r.Id) + return outcomeDone, err } - s.logger.Error("Failed to check recognition status", "error", err, "operation_id", opId) - return err - } - - if recResult.IsInProgress() { - // Операция ещё не завершена. Шаг отработал без отказа, поэтому задержка - // здесь своя, числом, а число попыток обнуляется переходом: ожидание - // чужой операции попытку не тратит. - s.logger.Info("Operation in progress", "job_id", job.Id, "operation_id", opId) - delayTime := clock.Now().Add(nextCheckDelay) - job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime) - if err := s.jobRepo.Save(job, holder); err != nil { - s.logger.Error("Failed to save job", "error", err, "job_id", job.Id) - return err + if stored.Contents != "" { + text = stored.Contents } - return nil } - if recResult.IsFailed() { - errorText := recResult.GetError() - s.logger.Error("Operation failed", - "job_id", job.Id, - "operation_id", opId, - "error_message", errorText) - return s.failJob(job, holder, errors.New(errorText), "сбой при распознавании файла") + r.MoveToState(entity.StateDone) + if err := s.repos.Records.Save(r, holder); err != nil { + s.logger.Error("Failed to save record", "error", err, "record_id", r.Id) + return outcomeDone, err } - // Операция завершена, получаем результат - transcriptionText, err := s.recognizer.GetRecognitionText(ctx, opId) - if err != nil { - if ctxErr := ctx.Err(); ctxErr != nil { - s.logger.Info("Text fetch interrupted by shutdown", "job_id", job.Id) - return fmt.Errorf("text fetch interrupted: %w", ctxErr) - } - s.logger.Error("Failed to get recognition text", "error", err, "operation_id", opId) - return err - } - - s.logger.Info("Transcribe operation completed successfully", - "job_id", job.Id, - "operation_id", opId, - "text_length", len(transcriptionText)) - - if len(transcriptionText) == 0 { - return s.completeJob(job, holder, "Ой, кажется, на аудиозаписи нет текста.") - } - - // Завершаем задачу - return s.completeJob(job, holder, transcriptionText) + return outcomeDone, s.send(r, text) } -// findJob забирает задачу и отдаёт её вместе с признаком захвата, который шаг -// держит. Задача, захваченная сверх предела попыток, до шага не доходит: её -// переводят в «мертва» и сообщают об этом отправителю. -func (s *TranscribeService) findJob(state string, expiration time.Duration) (*entity.TranscribeJob, string, error) { - acquisitionId := uuid.NewString() - rottingTime := clock.Now().Add(-1 * expiration) - - job, err := s.jobRepo.FindAndAcquire(state, acquisitionId, rottingTime) - if err != nil { - // Признак узнаётся по смыслу: репозиторий вправе обернуть свой отказ - // пояснением, и приведение типа от этого сломалось бы молча. - var notFound *contract.JobNotFoundError - if errors.As(err, ¬Found) { - return nil, "", &contract.NoopJobError{State: state} - } - s.logger.Error("Failed to find and acquire job", "state", state, "error", err) - return nil, "", fmt.Errorf("failed find and acquire job: %s, %w", state, err) - } - - if job.Attempts > maxAttempts { - s.killJob(job, acquisitionId) - return nil, "", &contract.NoopJobError{State: state} - } - - return job, acquisitionId, nil +// failStep останавливает запись приговором шага и сообщает отправителю. +// +// Исход возвращается **явно**: остановка отказом шага не является — шаг +// рассудил об этой записи окончательно, — но и работой она не была, и +// засчитывать её успехом нельзя. +func (s *TranscribeService) failStep(r *entity.AudioRecord, holder, step string, stepErr error, humanText string) (stepOutcome, error) { + s.halt(r, holder, step, entity.HaltReasonStepFailed, stepErr.Error(), + fmt.Sprintf("При обработке записи произошла ошибка: %s", humanText)) + return outcomeHalted, nil } -// killJob переводит исчерпавшую попытки задачу в «мертва» и сообщает об этом -// отправителю. Инвариант «Принятая запись не теряется молча» допускает два -// исхода — задача пригодна к повтору либо об отказе сказано, — и молчаливая -// смерть не подходит ни под один. -func (s *TranscribeService) killJob(job *entity.TranscribeJob, holder string) { - s.logger.Error("Job exhausted its attempts", - "job_id", job.Id, - "state", job.State, - "attempts", job.Attempts) +// halt ставит признак остановки, считает её отказом, пишет строку журнала +// событий и сообщает отправителю. +// +// Сообщение уходит при **любой** причине остановки: инвариант проекта «Принятая +// запись не теряется молча» допускает два исхода — запись пригодна к повтору +// либо об отказе сказано, — а остановленная запись захвату не выдаётся, значит +// первый исход исключён. +// +// Счётчик растит **сама остановка**, а не воркер, и это не стилистика. +// Остановка по сторожам наступает в захвате, до всякого шага, и воркер о ней +// узнаёт признаком «работы нет» — а считать его в метрику запрещено инвариантом +// «`NoopJobError` — не ошибка». Значит единственное место, где известны и факт +// остановки, и рубеж, — здесь. +func (s *TranscribeService) halt(r *entity.AudioRecord, holder, step, reason, errText, humanText string) { + // Рубеж читается до остановки: она его не двигает, но читать состояние после + // мутации — привычка, из-за которой метка счётчика уже однажды разъехалась. + stage := r.State - job.Die(fmt.Sprintf("attempts exhausted: %d", job.Attempts)) + r.Halt(reason, errText) - if err := s.jobRepo.Save(job, holder); err != nil { - s.logger.Error("Failed to save dead job", "error", err, "job_id", job.Id) + if err := s.repos.Records.Save(r, holder); err != nil { + var lost *contract.LostAcquisitionError + if errors.As(err, &lost) { + return + } + s.logger.Error("Failed to halt record", "error", err, "record_id", r.Id) return } - s.notify(job, "Не удалось обработать запись: попытки исчерпаны.\nПожалуйста, попробуйте еще раз.") + metrics.WorkerJobCounter.WithLabelValues(stage, labelFailed).Inc() + + // Приговор шага и приговор сторожа — разные события, и журнал их различает: + // первый вынес шаг, рассудивший об этой записи, второй — счётчик, у которого + // кончилось терпение. + outcome := entity.EventOutcomeHalted + if reason == entity.HaltReasonStepFailed { + outcome = entity.EventOutcomeFailed + } + s.appendEvent(r, entity.EventOriginPipeline, step, outcome, reason, 0) + + s.notify(r, humanText+"\nПожалуйста, попробуйте еще раз.") } -// scheduleRetry снимает захват с отказавшей задачи и ставит нарастающую паузу. +// scheduleRetry снимает захват с отказавшей записи и ставит нарастающую паузу. // Захват, оставленный до конца срока, отложил бы повтор на часы. -func (s *TranscribeService) scheduleRetry(job *entity.TranscribeJob, holder string, stepErr error) { - // Шаг, потерявший захват, задачу уже не трогает: ею занят другой. +func (s *TranscribeService) scheduleRetry(r *entity.AudioRecord, holder string, stepErr error) { + // Шаг, потерявший захват, запись уже не трогает: ею занят другой. var lost *contract.LostAcquisitionError if errors.As(stepErr, &lost) { return } - // Остановка попытки не тратит: задача не виновата в том, что нас + // Остановка сервиса отказа не тратит: запись не виновата в том, что нас // перезапустили. Счётчик растёт при захвате, поэтому здесь его возвращают - // назад — иначе пять выкладок подряд уводят живую запись в «мертва» с - // приговором «попытки исчерпаны». + // назад — иначе пять выкладок подряд останавливают живую запись с приговором + // «попытки исчерпаны». if errors.Is(stepErr, context.Canceled) || errors.Is(stepErr, context.DeadlineExceeded) { - if job.Attempts > 0 { - job.Attempts-- + if r.Attempts > 0 { + r.Attempts-- } } - job.RetryAfter(clock.Now().Add(retryDelay(job.Attempts))) + r.RetryAfter(clock.Now().Add(retryDelay(r.Attempts))) - if err := s.jobRepo.Save(job, holder); err != nil { + if err := s.repos.Records.Save(r, holder); err != nil { var lostOnSave *contract.LostAcquisitionError if errors.As(err, &lostOnSave) { return } - s.logger.Error("Failed to schedule job retry", "error", err, "job_id", job.Id) + s.logger.Error("Failed to schedule record retry", "error", err, "record_id", r.Id) } } -// retryDelay растит паузу с числом попыток до потолка. +// retryDelay растит паузу с числом отказов до потолка. func retryDelay(attempts int) time.Duration { if attempts < 1 { attempts = 1 @@ -537,73 +869,53 @@ func retryDelay(attempts int) time.Duration { return delay } -func (s *TranscribeService) completeJob(job *entity.TranscribeJob, holder string, transcriptionText string) error { - // Обновляем задачу с результатом - job.Done(transcriptionText) - - // Сохраняем задачу в базу - if err := s.jobRepo.Save(job, holder); err != nil { - s.logger.Error("Failed to save job", "error", err, "job_id", job.Id) - return fmt.Errorf("failed to save job: %w", err) +// appendEvent пишет строку журнала событий записи. Отказ записи журнала шаг не +// роняет: работа сделана, а журнал никем не читается ради решения. +func (s *TranscribeService) appendEvent(r *entity.AudioRecord, origin, step, outcome, outcomeText string, duration time.Duration) { + event := &entity.RecordEvent{ + RecordID: r.Id, + Origin: origin, + Step: step, + Outcome: outcome, + OutcomeText: outcomeText, + DurationMs: duration.Milliseconds(), } - - // Отправляем распознанный текст обратно пользователю - return s.send(job, transcriptionText) -} - -func (s *TranscribeService) failJob(job *entity.TranscribeJob, holder string, jobErr error, humanErrorText string) error { - // Обновляем задачу с результатом - job.Fail(jobErr.Error()) - - // Сохраняем задачу в базу - if err := s.jobRepo.Save(job, holder); err != nil { - s.logger.Error("Failed to save job", "error", err, "job_id", job.Id) - return fmt.Errorf("failed to save job: %w", err) + if err := s.repos.Events.Append(event); err != nil { + s.logger.Error("Failed to append record event", "error", err, "record_id", r.Id) } - - errorMessage := fmt.Sprintf("При обработке задачи произошла ошибка: %s.\nПожалуйста, попробуйте еще раз.", humanErrorText) - return s.send(job, errorMessage) } // send отвечает отправителю там, откуда пришла запись. Отказ отправки поднимает // вверх: он принадлежит шагу. // // Кроме недоставки — её шаг записывает и завершается без отказа. Ответ уходит -// после того, как достигнутое состояние сохранено: работа к этой минуте -// сделана, и объявленный отказ засчитался бы воркеру сбоем и лёг бы владельцу -// записью отказа. Повтор делу не помогает — ни бот, ни адресат от ожидания не -// появятся, — поэтому причина недоставки живёт в журнале, а не в состоянии -// задачи. -// -// Служебные поля завершённой задачи отказ бы при этом не переписал: переход в -// терминальное состояние снимает захват, и повторная запись натыкается на -// «захват потерян». Довод держится на счётчике и журнале, а не на этом. -func (s *TranscribeService) send(job *entity.TranscribeJob, text string) error { - if job.Source != entity.SourceTelegram { +// после того, как достигнутый рубеж сохранён: работа к этой минуте сделана, и +// объявленный отказ засчитался бы воркеру сбоем и лёг бы владельцу записью +// отказа. Повтор делу не помогает — ни бот, ни адресат от ожидания не появятся, +// — поэтому причина недоставки живёт в журнале, а не в рубеже записи. +func (s *TranscribeService) send(r *entity.AudioRecord, text string) error { + if r.Source != entity.SourceTelegram { return nil } - // Адресата у задачи нет: отвечать некуда, и повторять нечего. Уровень здесь - // выше, чем у неподнятого канала, и это не педантизм: пустой чат у задачи - // из Telegram — симптом порчи записи, а самый коварный её источник назван - // инвариантом «колонки очереди правятся в четырёх местах». Утони этот - // сигнал в одном ряду со штатным «бот не настроен» — и обнуление колонки - // заметит только отправитель, переставший получать ответы. - if job.TgChatId == nil { - s.undelivered(job, slog.LevelError, "chat is not specified") + // Адресата у записи нет: отвечать некуда, и повторять нечего. Уровень здесь + // выше, чем у неподнятого канала, и это не педантизм: пустой чат у записи + // из Telegram — симптом порчи записи. + if r.TgChatId == nil { + s.undelivered(r, slog.LevelError, "chat is not specified") return nil } - if err := s.tgSender.Send(text, *job.TgChatId, job.TgReplyMessageId); err != nil { + if err := s.tgSender.Send(text, *r.TgChatId, r.TgReplyMessageId); err != nil { // Канал не поднят: сервис работает без этого входа, и это объявленный // режим, а не поломка. if errors.Is(err, contract.ErrDeliveryChannelDown) { - s.undelivered(job, slog.LevelWarn, "delivery channel is down") + s.undelivered(r, slog.LevelWarn, "delivery channel is down") return nil } - s.logger.Error("Failed to sent message to client", "job_id", job.Id) - return fmt.Errorf("failed to sent message to client, job id: %s, err: %w", job.Id, err) + s.logger.Error("Failed to sent message to client", "record_id", r.Id) + return fmt.Errorf("failed to sent message to client, record id: %s, err: %w", r.Id, err) } return nil @@ -613,32 +925,26 @@ func (s *TranscribeService) send(job *entity.TranscribeJob, text string) error { // приходит от причины: объявленный режим — «может стать проблемой», порча // записи — событие для разбора. // -// Идентификатор задачи обязателен, иначе владелец видит, что ответ не ушёл, но +// Идентификатор записи обязателен, иначе владелец видит, что ответ не ушёл, но // не может найти, чей; текста ответа в записи нет — он содержимое чужой записи. -// -// Счётчик нужен потому, что журнал контейнера живёт до ротации, а вопрос «кому -// не ответили за последние сутки» задают позже. -func (s *TranscribeService) undelivered(job *entity.TranscribeJob, level slog.Level, reason string) { +func (s *TranscribeService) undelivered(r *entity.AudioRecord, level slog.Level, reason string) { metrics.UndeliveredReplyCounter.WithLabelValues(reason).Inc() - // Уровень выбирается ветвлением, а не передачей контекста: контекст здесь - // брать неоткуда — ответ идёт после сохранения состояния, — а выдуманный - // `context.Background()` соврал бы про отмену и цеплялся бы правилами. switch level { case slog.LevelError: - s.logger.Error(undeliveredMessage, "job_id", job.Id, "reason", reason) + s.logger.Error(undeliveredMessage, "record_id", r.Id, "reason", reason) default: - s.logger.Warn(undeliveredMessage, "job_id", job.Id, "reason", reason) + s.logger.Warn(undeliveredMessage, "record_id", r.Id, "reason", reason) } } const undeliveredMessage = "Reply was not delivered" -// notify отвечает отправителю там, где поднимать отказ некуда: задача уже -// доведена до конца, и отказ отправки остаётся записью в журнале владельца. -func (s *TranscribeService) notify(job *entity.TranscribeJob, text string) { - if err := s.send(job, text); err != nil { - s.logger.Error("Failed to notify sender", "error", err, "job_id", job.Id) +// notify отвечает отправителю там, где поднимать отказ некуда: запись уже +// доведена до своего исхода, и отказ отправки остаётся записью в журнале. +func (s *TranscribeService) notify(r *entity.AudioRecord, text string) { + if err := s.send(r, text); err != nil { + s.logger.Error("Failed to notify sender", "error", err, "record_id", r.Id) } } @@ -650,13 +956,28 @@ func (s *TranscribeService) closeWork(work contract.WorkFile) { } } -// ownerOf — владелец задачи строкой; пустая значит «владельца нет», и таковы -// записи, принятые ботом. Файл наследует владельца своей задачи: правило -// просмотра коллекции файлов сужено этой колонкой, и файл, заведённый шагом -// конвейера без неё, перестал бы доставаться собственному владельцу. -func ownerOf(job *entity.TranscribeJob) string { - if job.OwnerID == nil { +// ownerOf — владелец записи строкой; пустая значит «владельца нет», и таковы +// записи, принятые ботом. Файл наследует владельца своей записи: правило +// просмотра коллекции файлов сужено этой колонкой. +func ownerOf(r *entity.AudioRecord) string { + if r.OwnerID == nil { return "" } - return *job.OwnerID + return *r.OwnerID } + +// formatOf приводит расширение к виду колонки формата: без точки, в нижнем +// регистре. Наружу оно выходит только приведённым к перечню известных форматов — +// это делает метка метрики. +func formatOf(ext string) string { + return strings.ToLower(strings.TrimPrefix(ext, ".")) +} + +// Метки счётчика работы. Строками, а не приведением булева: значение метки — +// часть наблюдаемой поверхности, и опечатка в ней разводит один ряд на два. +const ( + labelOk = "false" + labelFailed = "true" +) + +func ptr[T any](v T) *T { return &v } diff --git a/internal/service/undelivered_test.go b/internal/service/undelivered_test.go index f1cd1b7..a9d662d 100644 --- a/internal/service/undelivered_test.go +++ b/internal/service/undelivered_test.go @@ -14,10 +14,10 @@ import ( "git.vakhrushev.me/av/transcriber/internal/entity" ) -// Ответ отправителю уходит после того, как достигнутое состояние сохранено. -// Значит, недоставка не может быть отказом шага: объявленный отказ засчитался -// бы воркеру сбоем, лёг бы владельцу записью отказа и переписал бы служебные -// поля завершённой задачи. Причин недоставки две, исход у них общий. +// Ответ отправителю уходит после того, как достигнутый рубеж сохранён. Значит, +// недоставка не может быть отказом шага: объявленный отказ засчитался бы воркеру +// сбоем, лёг бы владельцу записью отказа и переписал бы служебные поля +// доведённой записи. Причин недоставки две, исход у них общий. // downSender изображает неподнятый канал доставки: так ведёт себя заглушка, // которую ядро получает вместо отправителя Telegram. @@ -31,91 +31,105 @@ func (s *downSender) Send(string, int64, *int) error { } // journalEnv пересобирает сервис с названным отправителем и своим журналом: -// утверждения судят и состояние задачи, и то, что увидел владелец. +// утверждения судят и состояние записи, и то, что увидел владелец. func journalEnv( t *testing.T, env *pipelineEnv, - rec contract.AudioRecognizer, sender contract.TelegramMessageSender, ) (*TranscribeService, *bytes.Buffer) { t.Helper() journal := &bytes.Buffer{} svc := NewTranscribeService( - env.jobRepo, - env.fileRepo, + env.repos, &okMetaViewer{}, - &failingConverter{}, - rec, + &okConverter{}, + env.service.recognizer, sender, + testLimits, slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug})), ) return svc, journal } -// Канал не поднят: задача доводится до конца, шаг отказа не объявляет, а +// transcribedRecord доводит запись до рубежа, с которого уходит ответ. +func transcribedRecord(t *testing.T, env *pipelineEnv, svc *TranscribeService) *entity.AudioRecord { + t.Helper() + + record := newTelegramRecord(t, env) + for range 5 { + clearDelay(t, env, record.Id) + require.NoError(t, svc.RunStep(t.Context())) + if readRecord(t, env, record.Id).State == entity.StateTranscribed { + return readRecord(t, env, record.Id) + } + } + t.Fatal("запись не дошла до рубежа расшифровки") + return nil +} + +// Канал не поднят: запись доводится до конца, шаг отказа не объявляет, а // владелец узнаёт о недоставке из журнала. -func TestUndeliveredOnDownChannelKeepsJobDone(t *testing.T) { - env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) - rec := &scriptedRecognizer{result: entity.NewInProgressResult()} - job := transcribingJob(t, env, rec) +func TestUndeliveredOnDownChannelKeepsRecordDone(t *testing.T) { + env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) - rec.result = entity.NewCompletedResult() - rec.text = "расшифровка записи" sender := &downSender{} - svc, journal := journalEnv(t, env, rec, sender) + svc, journal := journalEnv(t, env, sender) + + record := transcribedRecord(t, env, svc) // Шаг завершается без отказа — именно это воркер считает в свой счётчик. - require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context())) + clearDelay(t, env, record.Id) + require.NoError(t, svc.RunStep(t.Context())) assert.Equal(t, 1, sender.calls, "ответ до отправителя доехал") - after, err := readJob(env.app, job.Id) + after := readRecord(t, env, record.Id) + assert.Equal(t, entity.StateDone, after.State, "запись осталась на достигнутом рубеже") + assert.False(t, after.IsHalted(), "отказ записи не приписан") + require.NotNil(t, after.TranscriptTextID, "расшифровка сохранена") + + text, err := env.repos.Texts.GetByID(*after.TranscriptTextID) require.NoError(t, err) - assert.Equal(t, entity.StateDone, after.State, "задача осталась в достигнутом состоянии") - require.NotNil(t, after.TranscriptionText) - assert.Equal(t, "расшифровка записи", *after.TranscriptionText, "расшифровка сохранена") - assert.Nil(t, after.ErrorText, "отказ задаче не приписан") + require.NotEmpty(t, text.Contents) written := journal.String() assert.Contains(t, written, "Reply was not delivered", "недоставка названа") - assert.Contains(t, written, job.Id, "запись несёт идентификатор задачи") + assert.Contains(t, written, record.Id, "запись несёт идентификатор") assert.Contains(t, written, "level=WARN", "объявленный режим — «может стать проблемой»") - assert.NotContains(t, written, "расшифровка записи", "текста расшифровки в журнале нет") + assert.NotContains(t, written, text.Contents, "текста расшифровки в журнале нет") } -// Адресат у задачи не назван: исход тот же. Прежде эта ветка объявляла отказ +// Адресат у записи не назван: исход тот же. Прежде эта ветка объявляла отказ // шага на уже завершённой работе. -func TestUndeliveredWithoutChatKeepsJobDone(t *testing.T) { - env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) - rec := &scriptedRecognizer{result: entity.NewInProgressResult()} - job := transcribingJob(t, env, rec) +func TestUndeliveredWithoutChatKeepsRecordDone(t *testing.T) { + env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) - // Задача из Telegram, у которой чат не назван: такую отдаёт правка в панели. - // Колонка чистится мимо захвата — иначе setup унёс бы задачу у шага. - record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id) - require.NoError(t, err) - record.Set("tg_chat_id", nil) - require.NoError(t, env.app.Save(record)) - - rec.result = entity.NewCompletedResult() - rec.text = "расшифровка записи" sender := &downSender{} - svc, journal := journalEnv(t, env, rec, sender) + svc, journal := journalEnv(t, env, sender) - require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context())) + record := transcribedRecord(t, env, svc) + + // Запись из Telegram, у которой чат не назван: такую отдаёт правка в панели. + // Колонка чистится мимо захвата — иначе подготовка унесла бы запись у шага. + stored, err := env.app.FindRecordById(migrations.RecordsCollection, record.Id) + require.NoError(t, err) + stored.Set("tg_chat_id", nil) + require.NoError(t, env.app.Save(stored)) + + clearDelay(t, env, record.Id) + require.NoError(t, svc.RunStep(t.Context())) assert.Equal(t, 0, sender.calls, "до отправителя дело не дошло: адресата нет") - after, err := readJob(env.app, job.Id) - require.NoError(t, err) + after := readRecord(t, env, record.Id) assert.Equal(t, entity.StateDone, after.State) - assert.Nil(t, after.ErrorText, "отказ задаче не приписан") + assert.False(t, after.IsHalted(), "отказ записи не приписан") written := journal.String() assert.Contains(t, written, "Reply was not delivered") - assert.Contains(t, written, job.Id) + assert.Contains(t, written, record.Id) assert.Contains(t, written, "chat is not specified", "причина названа") assert.Contains(t, written, "level=ERROR", "порча записи громче штатного «бот не настроен»: иначе сигнал утонет") @@ -123,17 +137,17 @@ func TestUndeliveredWithoutChatKeepsJobDone(t *testing.T) { // Запись, принятая по HTTP, до отправителя не доходит вовсе: недоставки нет, и // записи о ней в журнале быть не должно — иначе журнал владельца заполнят -// строки о задачах основного входа. -func TestApiJobDoesNotReachSenderAndLogsNothing(t *testing.T) { - env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) +// строки о записях основного входа. +func TestApiRecordDoesNotReachSenderAndLogsNothing(t *testing.T) { + env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{}) - job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "voice.ogg", newOwner(t, env.app)) + record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "voice.ogg", newOwner(t, env.app)) require.NoError(t, err) sender := &downSender{} - svc, journal := journalEnv(t, env, &scriptedRecognizer{}, sender) + svc, journal := journalEnv(t, env, sender) - require.NoError(t, svc.send(job, "расшифровка записи")) + require.NoError(t, svc.send(record, "расшифровка записи")) assert.Equal(t, 0, sender.calls, "отправителя не звали") assert.NotContains(t, journal.String(), "Reply was not delivered", "недоставки не было") diff --git a/main.go b/main.go index e2a5ea0..8556d62 100644 --- a/main.go +++ b/main.go @@ -68,6 +68,14 @@ func main() { os.Exit(1) } + // Числа конвейера проверяются здесь же: ноль воркеров — объявленный режим, а + // отрицательное число и нулевой предел простоя — опечатка, и подниматься с + // ней значит остановить всякую запись первым же захватом. + if err := cfg.Pipeline.Validate(); err != nil { + logger.Error("Unable to start with incorrect pipeline settings", "error", err) + os.Exit(1) + } + // Загружаем переменные окружения из .env файла if err := godotenv.Load(); err != nil { logger.Warn("Warning: .env file not found, using system environment variables") @@ -90,8 +98,15 @@ func main() { pbrepo.BindPanelRules(storage) // Создаем репозитории - fileRepo := pbrepo.NewFileRepository(storage) - jobRepo := pbrepo.NewTranscriptJobRepository(storage) + recordRepo := pbrepo.NewAudioRecordRepository(storage) + repos := service.Repositories{ + Records: recordRepo, + Files: pbrepo.NewFileRepository(storage), + Texts: pbrepo.NewTextRepository(storage), + Structures: pbrepo.NewStructureRepository(storage), + Recognitions: pbrepo.NewRecognitionRepository(storage), + Events: pbrepo.NewRecordEventRepository(storage), + } // Создаем адаптеры metaviewer := ffmpegmv.NewFfmpegMetaViewer() @@ -128,12 +143,12 @@ func main() { // Создаем сервисы transcribeService := service.NewTranscribeService( - jobRepo, - fileRepo, + repos, metaviewer, converter, recognizer, tgSender, + cfg.Pipeline.StuckLimits(), logger, ) @@ -154,7 +169,7 @@ func main() { // же факте. var tgController *tgcontroller.TelegramController if tgBot != nil { - tgController, err = tgcontroller.NewTelegramController(tgConfig, tgBot, transcribeService, jobRepo, logger) + tgController, err = tgcontroller.NewTelegramController(tgConfig, tgBot, transcribeService, logger) if err != nil { logger.Error("Failed to create Telegram controller", "error", err) os.Exit(1) @@ -170,26 +185,14 @@ func main() { }() } - // Создаем воркеры - conversionWorker := worker.NewCallbackWorker("conversion_worker", transcribeService.FindAndRunConversionJob, logger) - transcribeWorker := worker.NewCallbackWorker("transcribe_worker", transcribeService.FindAndRunTranscribeJob, logger) - checkWorker := worker.NewCallbackWorker("check_worker", transcribeService.FindAndRunTranscribeCheckJob, logger) - - workers := []worker.Worker{ - conversionWorker, - transcribeWorker, - checkWorker, - } - - // Запускаем воркеры в отдельных горутинах - for _, w := range workers { - wg.Add(1) - go func(worker worker.Worker) { - defer wg.Done() - worker.Start(ctx) - logger.Info("Worker stopped gracefully", "worker", worker.Name()) - }(w) - } + // Пул одинаковых воркеров: специализации у них нет, шаг выбирается по рубежу + // самой записи. Число приходит настройкой, ноль — законное значение. + pool := worker.NewPool(cfg.Pipeline.Workers, transcribeService.RunStep, logger) + wg.Add(1) + go func() { + defer wg.Done() + pool.Start(ctx) + }() // Вход по HTTP поднимается всегда: он основной, и отдельного разреза у него // нет. Признак ставится рядом с признаком Telegram, чтобы владелец судил об @@ -198,7 +201,7 @@ func main() { // Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом, // и второму серверу на нём взяться неоткуда. - transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService, logger) + transcribeHandler := httpcontroller.NewTranscribeHandler(recordRepo, repos.Texts, transcribeService, logger) authHandler := httpcontroller.NewAuthHandler(storage, httpcontroller.AuthHandlerConfig{ AuthURL: cfg.Auth.AuthURL, RedirectURL: cfg.Auth.RedirectURL, @@ -291,8 +294,7 @@ func main() { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - logger.Info("Transcriber service started with background workers") - logger.Info("Workers: ConversionWorker, TranscribeWorker, CheckWorker") + logger.Info("Transcriber service started", "pipeline_workers", pool.Size()) logger.Info("Press Ctrl+C to stop...") // Ждем сигнал завершения либо отказ сервера diff --git a/openspec/changes/archive/2026-08-14-record-centric-model/.openspec.yaml b/openspec/changes/archive/2026-08-14-record-centric-model/.openspec.yaml new file mode 100644 index 0000000..4af8641 --- /dev/null +++ b/openspec/changes/archive/2026-08-14-record-centric-model/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-14 diff --git a/openspec/changes/archive/2026-08-14-record-centric-model/design.md b/openspec/changes/archive/2026-08-14-record-centric-model/design.md new file mode 100644 index 0000000..05e3211 --- /dev/null +++ b/openspec/changes/archive/2026-08-14-record-centric-model/design.md @@ -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` в плане +стройки стоит **ниже** этой задачи, хотя «Рамки» постановки называют её +предшествующей. Порядок расставил владелец, и решение о нём принято. diff --git a/openspec/changes/archive/2026-08-14-record-centric-model/proposal.md b/openspec/changes/archive/2026-08-14-record-centric-model/proposal.md new file mode 100644 index 0000000..c07ef72 --- /dev/null +++ b/openspec/changes/archive/2026-08-14-record-centric-model/proposal.md @@ -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` в плане стройки стоит + ниже, а по «Рамкам» постановки предшествует — без предела по времени у шага + срок захвата не может его превысить. diff --git a/openspec/changes/archive/2026-08-14-record-centric-model/review/triage.md b/openspec/changes/archive/2026-08-14-record-centric-model/review/triage.md new file mode 100644 index 0000000..a379fc1 --- /dev/null +++ b/openspec/changes/archive/2026-08-14-record-centric-model/review/triage.md @@ -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//<запись>/<имя>.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` +в нём нет потому, что ни одна находка не получила оракула, поднимающего её до +нарушения инварианта необратимого класса, — а не потому, что таких свойств не искали +и не нашли. diff --git a/openspec/changes/archive/2026-08-14-record-centric-model/specs/intake/spec.md b/openspec/changes/archive/2026-08-14-record-centric-model/specs/intake/spec.md new file mode 100644 index 0000000..82f35c8 --- /dev/null +++ b/openspec/changes/archive/2026-08-14-record-centric-model/specs/intake/spec.md @@ -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` и сообщение о ненайденной записи diff --git a/openspec/changes/archive/2026-08-14-record-centric-model/specs/pipeline/spec.md b/openspec/changes/archive/2026-08-14-record-centric-model/specs/pipeline/spec.md new file mode 100644 index 0000000..57fd9fc --- /dev/null +++ b/openspec/changes/archive/2026-08-14-record-centric-model/specs/pipeline/spec.md @@ -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**: Обязанности разведены по двум требованиям — «Число отказов +ограничивает повторы шага» и «Время в рубеже ограничено». Состояния «мертва» и +отказа заменены признаком остановки с причиной, который рубежа не стирает: +требование «Остановка записи — признак, а не рубеж»; туда же дословно перенесены +запрет на второй способ вывести запись из выборки и правило «перевод принадлежит +одному месту». Обязанность сообщить отправителю вынесена в общее требование +«Всякая остановка сообщает отправителю»: причин остановки стало больше одной, и +обязанность, записанная у одной из них, у остальных читалась бы как снятая. +Возврат в работу по-прежнему делает владелец, но снятием признака, а не правкой +состояния. diff --git a/openspec/changes/archive/2026-08-14-record-centric-model/specs/recognition/spec.md b/openspec/changes/archive/2026-08-14-record-centric-model/specs/recognition/spec.md new file mode 100644 index 0000000..55e3127 --- /dev/null +++ b/openspec/changes/archive/2026-08-14-record-centric-model/specs/recognition/spec.md @@ -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** повторная отправка той же записи не заводится diff --git a/openspec/changes/archive/2026-08-14-record-centric-model/specs/storage/spec.md b/openspec/changes/archive/2026-08-14-record-centric-model/specs/storage/spec.md new file mode 100644 index 0000000..d356ea8 --- /dev/null +++ b/openspec/changes/archive/2026-08-14-record-centric-model/specs/storage/spec.md @@ -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** удаление проходит diff --git a/openspec/changes/archive/2026-08-14-record-centric-model/tasks.md b/openspec/changes/archive/2026-08-14-record-centric-model/tasks.md new file mode 100644 index 0000000..9c567df --- /dev/null +++ b/openspec/changes/archive/2026-08-14-record-centric-model/tasks.md @@ -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` diff --git a/openspec/specs/intake/spec.md b/openspec/specs/intake/spec.md index 9acd2af..b53b9cd 100644 --- a/openspec/specs/intake/spec.md +++ b/openspec/specs/intake/spec.md @@ -18,18 +18,22 @@ Telegram, дописывает его сюда. Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с телом `multipart/form-data` и полем `audio` **только от узнанного отправителя**. Запрос без сессии MUST получать код `401`, и по нему MUST не заводиться ни файл, -ни задача расшифровки. Принятая запись от узнанного отправителя MUST быть -сохранена и получить заведённую под неё задачу расшифровки в состоянии -`created`; ответ MUST нести идентификатор задачи полем `job_id` и её состояние -полем `status`. +ни аудиозапись. Принятая запись от узнанного отправителя MUST быть сохранена и +получить заведённую под неё аудиозапись на рубеже `uploaded`; ответ MUST нести +идентификатор записи полем `job_id` и её рубеж полем `status`. + +Значение рубежа в ответе изменилось: прежде приём отдавал `created`. Перечень +состояний назван проектом необратимым, и ломка объявлена прямо — состояние +теперь называет достигнутое, а не предстоящее, и `created` в новом перечне нет +вовсе. + +Имена полей ответа нормативны и MUST остаться прежними: контракт HTTP API +объявлен проектом необратимым, и переименование поля ломает внешнюю программу +молча. Меняются значения поля рубежа, а не его имя. Отказ по отсутствию сессии наступает **раньше** чтения тела: запись, за которую не заплатит узнанный отправитель, не должна попасть даже в память. -Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым, -и переименование поля ломает внешнюю программу молча. Появление отказа без -сессии — намеренная ломка этого контракта: до неё приём стоял открытым наружу. - Приём не судит о годности записи сам: расширение он берёт из имени файла, а пригодность содержимого узнаёт у источника метаданных. @@ -61,30 +65,30 @@ Telegram, дописывает его сюда. - **AND** отправитель предъявил сессию - **WHEN** программа шлёт `POST /api/audio` с полем `audio` - **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status` - со значением `created` + со значением `uploaded` - **AND** содержимое записи целиком лежит в хранилище одним файлом -- **AND** владельцем заведённой задачи стоит предъявитель сессии +- **AND** владельцем заведённой аудиозаписи стоит предъявитель сессии #### Scenario: Сессия не даёт учётной записи пользователя - **GIVEN** предъявлена сессия владельца панели - **WHEN** он шлёт `POST /api/audio` с полем `audio` - **THEN** ответ имеет код `403` -- **AND** ни файла, ни задачи не заводится +- **AND** ни файла, ни аудиозаписи не заводится #### Scenario: Сессии нет - **WHEN** программа шлёт `POST /api/audio` с полем `audio` без сессии - **THEN** ответ имеет код `401` -- **AND** ни файла, ни задачи не заводится -- **AND** тело ответа не несёт данных задачи +- **AND** ни файла, ни аудиозаписи не заводится +- **AND** тело ответа не несёт данных записи #### Scenario: Поля с записью нет - **GIVEN** отправитель предъявил сессию - **WHEN** программа шлёт `POST /api/audio` без поля `audio` - **THEN** ответ имеет код `400` и сообщение об отсутствии записи -- **AND** ни файла, ни задачи не заводится +- **AND** ни файла, ни аудиозаписи не заводится #### Scenario: Размеру записи приём не судья @@ -237,58 +241,86 @@ Telegram, дописывает его сюда. ### Requirement: Опрос готовности задачи -Сервис SHALL отдавать состояние задачи расшифровки по запросу -`GET /api/status/:id` **только её владельцу**. Запрос без сессии MUST получать -код `401`, и тело такого ответа MUST не нести ни состояния задачи, ни текста -расшифровки. Ответ владельцу MUST нести идентификатор полем `job_id`, состояние -полем `status` и время заведения полем `created_at`, а текст расшифровки полем -`transcription_text`, и это поле MUST отсутствовать в ответе, пока текста нет: -пустая строка на месте отсутствующего текста читается как «расшифровка пуста». +Сервис SHALL отдавать рубеж аудиозаписи по запросу `GET /api/status/:id` +**только её владельцу**. Запрос без сессии MUST получать код `401`, и тело +такого ответа MUST не нести ни рубежа записи, ни текста расшифровки. Ответ +владельцу MUST нести идентификатор полем `job_id`, рубеж полем `status` и время +заведения полем `created_at`, а текст расшифровки полем `transcription_text`, и +это поле MUST отсутствовать в ответе, пока текста нет: пустая строка на месте +отсутствующего текста читается как «расшифровка пуста». -Отказ без сессии MUST не зависеть от того, есть такая задача или нет: иначе по -кодам ответа перебирается список заведённых задач. +Видов текста у записи больше одного, поэтому ответ MUST называть вид, который +отдаёт: в поле `transcription_text` уходит **сырая расшифровка**, и только она. +Вычитанный текст этим полем MUST не подменяться — иначе значение поля менялось бы +у одной и той же записи от того, успел ли отработать необязательный шаг, а +контракт объявлен необратимым. Отдача «последнего записанного» текста MUST не +применяться: она делает ответ функцией порядка записи, а не состояния записи. -Задача, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный -идентификатор, — кодом `404` и тем же телом. То же MUST относиться к задаче без +Перечень значений поля `status` MUST совпадать с перечнем рубежей конвейера: +`uploaded`, `normalized`, `submitted`, `transcribed`, `done`. Прежних значений +`created`, `converted`, `transcribe`, `failed` и `dead` в ответе MUST не быть. +Это объявленная ломка публичного контракта: рубеж называет достигнутое, а отказ +перестал быть состоянием. + +Остановленная запись MUST отдавать рубеж, на котором она остановлена, и MUST +нести признак остановки отдельным полем `halted` со значением истины. Машинный +текст отказа MUST в ответ не попадать: он принадлежит журналу владельца сервиса, +а не отправителю. Отправитель узнаёт о неудаче ответом там, откуда пришла +запись, — это нормирует capability `pipeline`. + +Отказ без сессии MUST не зависеть от того, есть такая запись или нет: иначе по +кодам ответа перебирается список заведённых записей. + +Запись, принадлежащая другому, MUST отвечать тем же, чем отвечает неизвестный +идентификатор, — кодом `404` и тем же телом. То же MUST относиться к записи без владельца: запись, принятая ботом, по этому адресу не достаётся никому. -#### Scenario: Задача найдена +#### Scenario: Запись найдена - **GIVEN** отправитель предъявил сессию -- **WHEN** он спрашивает состояние своей задачи +- **WHEN** он спрашивает рубеж своей записи - **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at` +- **AND** значение `status` принадлежит перечню рубежей конвейера + +#### Scenario: Запись остановлена + +- **GIVEN** запись остановлена признаком на рубеже приведения +- **WHEN** владелец спрашивает её рубеж +- **THEN** поле `status` несёт рубеж приведения +- **AND** поле `halted` несёт истину +- **AND** машинного текста отказа в ответе нет #### Scenario: Сессии нет -- **WHEN** программа спрашивает состояние заведённой задачи без сессии +- **WHEN** программа спрашивает рубеж заведённой записи без сессии - **THEN** ответ имеет код `401` -- **AND** тело ответа не несёт ни состояния задачи, ни текста расшифровки +- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки -#### Scenario: Без сессии неизвестная задача неотличима от заведённой +#### Scenario: Без сессии неизвестная запись неотличима от заведённой -- **WHEN** программа без сессии спрашивает состояние заведённой задачи, а затем - состояние по неизвестному идентификатору +- **WHEN** программа без сессии спрашивает рубеж заведённой записи, а затем + рубеж по неизвестному идентификатору - **THEN** оба ответа имеют код `401` -#### Scenario: Чужая задача неотличима от неизвестной +#### Scenario: Чужая запись неотличима от неизвестной -- **GIVEN** задача заведена одним вошедшим -- **WHEN** её состояние спрашивает другой вошедший +- **GIVEN** запись заведена одним вошедшим +- **WHEN** её рубеж спрашивает другой вошедший - **THEN** ответ имеет код `404` и то же тело, что и ответ по неизвестному идентификатору -- **AND** тело ответа не несёт ни состояния задачи, ни текста расшифровки +- **AND** тело ответа не несёт ни рубежа записи, ни текста расшифровки #### Scenario: Расшифровки ещё нет - **GIVEN** отправитель предъявил сессию -- **WHEN** он спрашивает состояние своей задачи, которая ещё не дошла до текста +- **WHEN** он спрашивает рубеж своей записи, которая ещё не дошла до текста - **THEN** поля `transcription_text` в ответе нет вовсе -#### Scenario: Задачи с таким идентификатором нет +#### Scenario: Записи с таким идентификатором нет - **GIVEN** отправитель предъявил сессию -- **WHEN** программа спрашивает состояние по неизвестному идентификатору -- **THEN** ответ имеет код `404` и сообщение о ненайденной задаче +- **WHEN** программа спрашивает рубеж по неизвестному идентификатору +- **THEN** ответ имеет код `404` и сообщение о ненайденной записи ### Requirement: Поднятые входы видны наблюдателю diff --git a/openspec/specs/pipeline/spec.md b/openspec/specs/pipeline/spec.md index d90a682..743bd23 100644 --- a/openspec/specs/pipeline/spec.md +++ b/openspec/specs/pipeline/spec.md @@ -2,28 +2,36 @@ ## Purpose -Конвейер расшифровки: как задача движется по состояниям, что делает воркер, +Конвейер расшифровки: как аудиозапись движется по рубежам, что делает воркер, когда работы нет, что считается отказом шага и что бывает с ответом отправителю, когда доставить его некуда. -Описаны пустой прогон воркера, неделимость захвата и срок его протухания, число -попыток и состояние «мертва», нарастающая пауза перед повтором, условие записи -результата держателем захвата и недоставка ответа при неподнятом входе. -Сознательно не описаны: цепочка переходов `created → converted → transcribe → -done | failed`, отмена контекста посреди шага и освобождение ресурсов внешних -клиентов. Это не значит, что такого поведения нет: оно живёт в коде, а -требования на него не написаны, потому что требование без проверки — -предположение, а не норма. Первая задача, которая трогает любое из -перечисленного, дописывает его сюда. +Описаны цепочка рубежей и смысл рубежа, остановка признаком и её причины, оба +сторожа — число отказов и время в рубеже, — откладывание работы отдельно от +перехода, неделимость захвата и срок его протухания, условие записи результата +держателем захвата, нарастающая пауза перед повтором, число воркеров настройкой, +журнал событий записи и недоставка ответа при неподнятом входе. + +Сознательно не описаны: освобождение ресурсов внешних клиентов и **какие отказы +считаются приговором записи, а какие поводом к повтору**. Второе — не пробел +формулировки, а неразобранный вопрос: сегодня отказ приведения останавливает +запись с первой попытки, и предел отказов на нём не работает никогда. Это не +значит, что поведения нет: оно живёт в коде, а требования на него не написаны, +потому что требование без проверки — предположение, а не норма. Первая задача, +которая трогает любое из перечисленного, дописывает его сюда. ## Requirements ### Requirement: Пустой прогон воркера — не отказ -Воркер SHALL отличать «работы в этом состоянии сейчас нет» от отказа шага. На +Воркер SHALL отличать «пригодной к работе записи сейчас нет» от отказа шага. На пустом прогоне он MUST не считать прогон отказом: не увеличивать счётчик работы и не писать о нём на уровне владельца сервиса. Признак пустого прогона MUST узнаваться по смыслу значения, а не по его точной форме, и MUST переживать пояснения, добавленные к этому значению на любом промежуточном шаге пути. +Формулировка сменилась вместе с моделью: воркер больше не привязан к рубежу и +опрашивает не «своё состояние», а очередь целиком, поэтому пустой прогон значит +«работы нет ни на одном рубеже», а не «работы нет в этом состоянии». + Требование стоит на инварианте проекта «`NoopJobError` — не ошибка»: воркеры опрашивают базу раз в секунду, и пустой прогон, принятый за отказ, даёт от каждого запись отказа в секунду и столько же засчитанных сбоев, которых не было. @@ -31,12 +39,12 @@ done | failed`, отмена контекста посреди шага и ос Признак пустого прогона MUST рождаться только ответом хранилища на опрос этим же шагом. Слой, придающий отказу собственный смысл, MUST не сохранять чужой признак в цепочке своей ошибки. Воркер узнаёт признак по смыслу на любой глубине, поэтому -отказ, к которому признак примешался, тоже зачёл бы пустым прогоном: задача -осталась бы в своём состоянии и переопрашивалась раз в секунду без единой записи +отказ, к которому признак примешался, тоже зачёл бы пустым прогоном: запись +осталась бы на своём рубеже и переопрашивалась раз в секунду без единой записи — ровно то, что запрещает инвариант «Принятая запись не теряется молча». Отказ шага, наоборот, MUST быть виден владельцу сервиса записью в журнале и MUST -быть засчитан в счётчик работы с пометкой отказа. +быть засчитан в счётчик работы с пометкой отказа и с меткой рубежа. **Сколько раз он записывается и каким уровнем — это требование не нормирует, и умолчанием тут считать нечего.** Сегодня один отказ даёт две записи: пишет шаг @@ -48,16 +56,16 @@ done | failed`, отмена контекста посреди шага и ос двойную, — закрыло бы долг контрактом. Задача, которая возьмётся за этот долг, дописывает норму сюда. -#### Scenario: Работы в состоянии нет +#### Scenario: Пригодной к работе записи нет -- **GIVEN** ни одной задачи в опрашиваемом состоянии нет +- **GIVEN** ни одной записи, пригодной к работе, нет ни на одном рубеже - **WHEN** воркер делает свой прогон - **THEN** на уровне владельца сервиса об этом прогоне не пишется ничего - **AND** счётчик работы воркера не растёт #### Scenario: Признак пустого прогона дошёл с пояснением -- **GIVEN** работы в опрашиваемом состоянии нет +- **GIVEN** пригодной к работе записи нет - **AND** промежуточный шаг добавил к этому признаку своё пояснение - **WHEN** воркер делает свой прогон - **THEN** прогон по-прежнему считается пустым: счётчик не растёт, записи на @@ -68,29 +76,45 @@ done | failed`, отмена контекста посреди шага и ос - **GIVEN** шаг конвейера вернул отказ - **WHEN** воркер завершает прогон - **THEN** отказ виден владельцу сервиса записью в журнале -- **AND** счётчик работы воркера растёт с пометкой отказа +- **AND** счётчик работы воркера растёт с пометкой отказа и меткой рубежа #### Scenario: Шаг сделал работу -- **GIVEN** шаг конвейера отработал задачу без отказа +- **GIVEN** шаг конвейера отработал запись без отказа - **WHEN** воркер завершает прогон - **THEN** счётчик работы воркера растёт с пометкой успеха - **AND** записи об отказе в журнале нет ### Requirement: Захват задачи неделим -Захват задачи воркером SHALL быть одним неделимым шагом хранилища: выбор -подходящей задачи и пометка её захваченной MUST происходить вместе, и захваченная -задача MUST возвращаться тем же шагом. +Захват записи воркером SHALL быть одним неделимым шагом хранилища: выбор +подходящей записи и пометка её захваченной MUST происходить вместе. -Одна и та же задача MUST доставаться ровно одному захватившему. Двум вызывающим, -пришедшим за одним состоянием одновременно, запись MUST достаться одному, а -второй MUST получить признак «работы в этом состоянии нет». +Захват MUST возвращать **идентификатор записи и признак этого захвата**, а не +перечень её колонок. Колонки записи шаг читает сам, обычным чтением. Иначе +всякая новая колонка аудиозаписи попадала бы под инвариант проекта о колонках +очереди, и забытая в захвате колонка приезжала бы нулевой, а первое же +сохранение писало бы этот ноль поверх сохранённого значения. + +**Признак захвата MUST быть значением, уникальным для каждого захвата**, а не +признаком занятости. Условие записи результата сверяет именно это значение: +захват, перевыданный другому — по протуханию срока или после того, как человек +снял признак остановки в панели, — обязан обращать запись первого в отказ. +Условие, проверяющее лишь непустоту признака или срок, пропустило бы обоих, и +два шага записали бы в одну запись и оба ответили бы отправителю. + +Одна и та же запись MUST доставаться ровно одному захватившему. Двум вызывающим, +пришедшим за работой одновременно, запись MUST достаться одному, а второй MUST +получить признак «работы сейчас нет». + +Срок протухания захвата MUST ехать с рубежом записи, а не с воркером: воркер не +привязан к шагу и не знает заранее, что вытянет. Срок MUST записываться числом +при самом захвате. Порядок выборки MUST быть определён однозначно: сравнения по неуникальному значению для этого мало, и к нему MUST добавляться ключ записи. Иначе порядок -обработки невоспроизводим, а проверка, опирающаяся на «следующую» задачу, -зелена через раз. +обработки невоспроизводим, а проверка, опирающаяся на «следующую» запись, зелена +через раз. Требование стоит на инварианте проекта «Принятая запись не теряется молча»: захват, разделённый на два шага, отдаёт одну запись двум воркерам, и работа @@ -99,41 +123,57 @@ done | failed`, отмена контекста посреди шага и ос Признак «работы нет» этим требованием не переопределяется — его нормирует требование «Пустой прогон воркера — не отказ». -#### Scenario: За задачей пришли трое разом +#### Scenario: За работой пришли трое разом -- **GIVEN** в опрашиваемом состоянии лежит ровно одна задача -- **WHEN** три захвата этого состояния идут одновременно +- **GIVEN** к работе пригодна ровно одна запись +- **WHEN** три захвата идут одновременно - **THEN** запись получает ровно один из них -- **AND** двое остальных получают признак «работы в этом состоянии нет» +- **AND** двое остальных получают признак «работы сейчас нет» -#### Scenario: Захваченная задача не выдаётся второй раз +#### Scenario: Захваченная запись не выдаётся второй раз -- **GIVEN** задача захвачена и срок захвата не истёк -- **WHEN** за тем же состоянием приходит следующий захват -- **THEN** эта задача ему не выдаётся +- **GIVEN** запись захвачена и срок захвата не истёк +- **WHEN** приходит следующий захват +- **THEN** эта запись ему не выдаётся + +#### Scenario: Захват отдаёт идентификатор и свой признак + +- **GIVEN** к работе пригодна запись +- **WHEN** воркер её захватывает +- **THEN** захват возвращает идентификатор записи и признак этого захвата +- **AND** колонки записи шаг читает отдельным чтением + +#### Scenario: Признак перевыданного захвата отличается от прежнего + +- **GIVEN** запись захвачена, и признак первого захвата известен +- **WHEN** человек снимает признак остановки, и запись захватывает другой воркер +- **THEN** признак нового захвата отличается от признака первого ### Requirement: Результат пишет только держатель захвата -Шаг конвейера SHALL записывать свой результат только тогда, когда захват задачи -всё ещё принадлежит ему. Запись MUST быть условна по признаку захвата, а шаг, -чей захват за время работы достался другому, MUST завершиться без записи +Шаг конвейера SHALL записывать свой результат только тогда, когда захват записи +всё ещё принадлежит ему. Запись MUST быть условна по **признаку этого захвата** — +значению, уникальному для каждого захвата, — а не по занятости записи вообще. +Шаг, чей захват за время работы достался другому, MUST завершиться без записи результата и без ответа отправителю. Требование закрывает то, чего неделимость захвата не закрывает: захват протухает не только у мёртвого воркера, но и у живого — шаг, идущий дольше своего срока, -теряет задачу, продолжая работать. Без этого условия два воркера пишут в одну -задачу по очереди, счётчик попыток сбрасывает тот, кто уже не владелец, а -отправитель получает два ответа на одну запись. +теряет запись, продолжая работать. Снять захват может и человек, вернувший +остановленную запись в работу. Без условия по уникальному признаку два воркера +пишут в одну запись по очереди, счётчик отказов сбрасывает тот, кто уже не +владелец, а отправитель получает два ответа на одну запись. -Шаг MUST записывать только те поля, которыми распоряжается сам. Задачу он держит +Шаг MUST записывать только те поля, которыми распоряжается сам. Запись он держит снимком с момента захвата и до записи — это часы, — и безусловная запись снимка стёрла бы всё, что владелец правил в панели за это время: молча, без строки в журнале и без отказа в панели. Владелец увидел бы успешное сохранение и был бы -уверен, что правка на месте. +уверен, что правка на месте. Владелец записи, заголовок, краткое описание и темы +конвейер MUST не трогать. #### Scenario: Правка владельца пережила сохранение шага -- **GIVEN** шаг держит захваченную задачу +- **GIVEN** шаг держит захваченную запись - **AND** владелец за это время изменил в панели поле, которого шаг не касается - **WHEN** шаг записывает свой результат - **THEN** результат шага записан @@ -141,157 +181,121 @@ done | failed`, отмена контекста посреди шага и ос #### Scenario: Захват ушёл под работающим шагом -- **GIVEN** шаг работает над захваченной задачей -- **AND** за это время та же задача досталась другому захвату +- **GIVEN** шаг работает над захваченной записью +- **AND** за это время та же запись досталась другому захвату - **WHEN** первый шаг доходит до записи результата - **THEN** результат не записывается - **AND** отправителю ничего не отправляется +#### Scenario: Человек снял остановку под работающим шагом + +- **GIVEN** шаг работает над захваченной записью +- **AND** человек за это время снял с неё признак остановки, освободив захват +- **AND** запись досталась другому воркеру +- **WHEN** первый шаг доходит до записи результата +- **THEN** результат не записывается + ### Requirement: Брошенная задача возвращается в работу -Задача, захваченная и брошенная на середине, SHALL доставаться снова по +Запись, захваченная и брошенная на середине, SHALL доставаться снова по истечении срока захвата. Срок MUST считаться от времени захвата, а истёкший -захват MUST не мешать выдать задачу следующему. +захват MUST не мешать выдать запись следующему. -Срок задаётся шагом конвейера и MUST быть не меньше того времени, которое этот -шаг может занять на самом длинном допустимом входе. Срок короче делает -протухание штатным событием живого шага, а не признаком беды. +Срок задаётся рубежом, с которого запись взята, и MUST быть не меньше того +времени, которое шаг этого рубежа может занять на самом длинном допустимом +входе. Срок короче делает протухание штатным событием живого шага, а не +признаком беды. Срок MUST записываться в саму запись при захвате: воркер шага не +знает и вывести срок из себя не может. -Все значения времени, по которым идёт этот отбор, MUST записываться и сравниваться -в одном виде — том же, в каком хранилище пишет собственные времена записи. -Сравнение идёт побайтово, и вид, разошедшийся хоть разделителем, обращает -условие в постоянную истину или постоянную ложь, причём молча. +Все значения времени, по которым идёт этот отбор, MUST записываться и +сравниваться в одном виде — том же, в каком хранилище пишет собственные времена +записи. Сравнение идёт побайтово, и вид, разошедшийся хоть разделителем, +обращает условие в постоянную истину или постоянную ложь, причём молча. #### Scenario: Захват протух -- **GIVEN** задача захвачена, а время захвата отстоит дальше срока -- **WHEN** за её состоянием приходит захват -- **THEN** задача выдаётся ему +- **GIVEN** запись захвачена, а время захвата отстоит дальше срока +- **WHEN** приходит захват +- **THEN** запись выдаётся ему #### Scenario: Срок сравнивается с временем, записанным хранилищем -- **GIVEN** задача захвачена, и время захвата записано в том же виде, в каком +- **GIVEN** запись захвачена, и время захвата записано в том же виде, в каком хранилище пишет время изменения записи -- **WHEN** за её состоянием приходит захват до истечения срока -- **THEN** задача ему не выдаётся +- **WHEN** приходит захват до истечения срока +- **THEN** запись ему не выдаётся -### Requirement: Число попыток и состояние «мертва» +#### Scenario: Срок протухания приехал с рубежом -У задачи SHALL быть число попыток. Оно MUST расти при каждом захвате и MUST -возвращаться к нулю, когда шаг завершился без отказа. Рост при захвате, а не при -отказе, засчитывает попытку и задаче, брошенной на середине: шаг, уносящий с -собой процесс, до объявления отказа не доходит никогда, и без этого такая задача -крутилась бы вечно. - -Задача, захваченная с числом попыток сверх заданного предела, MUST переводиться в -состояние «мертва» тем, кто её захватил, и MUST не отдаваться шагу в работу. Перевод -принадлежит одному месту: условие отбора, молча пропускающее задачу мимо выборки, -оставило бы её без состояния и без следа. - -Мёртвая задача MUST отбираться владельцем по своему состоянию и MUST -возвращаться в работу правкой этого состояния — без запроса в консоли сервера. - -Переход в «мертва» MUST сообщать отправителю о неудаче ровно так же, как -сообщает о ней отказ шага. Иначе он становится третьим исходом там, где инвариант -проекта «Принятая запись не теряется молча» допускает два: задача не пригодна к -повтору и об отказе никто не сказал. - -От состояния отказа «мертва» отличается тем, чей это приговор. В `failed` задачу -переводит шаг, рассудивший об этой записи окончательно: конвертация не удалась, -распознавание вернуло ошибку. В «мертва» задача уходит без такого суждения — мы -повторяли и перестали. Ни один шаг конвейера в «мертва» не переводит сам. - -Прежний признак «задача с ошибкой», исключавший задачу из выборки навсегда и -отдельный от перечня состояний, MUST не заводиться заново: два способа вывести -задачу из выборки расходятся, и молчаливо теряется тот, который забыли проверить. - -#### Scenario: Задача падает на каждой попытке - -- **GIVEN** шаг конвейера отказывает на каждой попытке -- **WHEN** задача проходит заданное число попыток -- **THEN** она переходит в состояние «мертва» -- **AND** следующий захват её не выдаёт -- **AND** отправитель получает сообщение о неудаче - -#### Scenario: Шаг уносит процесс, не объявив отказа - -- **GIVEN** шаг конвейера обрывается вместе с процессом на каждой попытке -- **WHEN** задача захватывается снова заданное число раз -- **THEN** она переходит в состояние «мертва» - -#### Scenario: Прошедшая задача попыток не копит - -- **GIVEN** задача прошла подряд несколько состояний без единого отказа -- **WHEN** смотрят её число попыток -- **THEN** оно не приблизилось к пределу - -#### Scenario: Мёртвая задача возвращена в работу - -- **GIVEN** задача в состоянии «мертва» -- **WHEN** её состояние сменили на то, с которого она отказывала -- **THEN** следующий захват выдаёт её снова +- **GIVEN** записи двух рубежей с разными сроками захвата пригодны к работе +- **WHEN** их захватывает один и тот же воркер +- **THEN** у каждой записан срок её рубежа ### Requirement: Пауза перед повтором нарастает -Перед повтором **отказавшей** задачи сервис SHALL выдерживать паузу, и пауза -MUST расти с числом её попыток до объявленного потолка. Задача MUST не +Перед повтором **отказавшей** записи сервис SHALL выдерживать паузу, и пауза +MUST расти с числом её отказов до объявленного потолка. Запись MUST не выдаваться захвату, пока пауза не кончилась. Ожидание чужой операции этой паузой MUST не выражаться. Шаг, увидевший, что -внешняя операция ещё идёт, отработал без отказа: он назначает **свою** задержку -опроса, заданную числом, и попытки при этом не тратит. Пауза, выведенная из -числа попыток, на таком шаге вырождается в наименьшее своё значение и учащает -опрос внешнего сервиса во столько раз, во сколько задержка опроса длиннее секунды. +внешняя операция ещё идёт, отработал без отказа: он **откладывает** работу своей +задержкой, заданной числом, и отказов при этом не тратит. Пауза, выведенная из +числа отказов, на таком шаге вырождается в наименьшее своё значение и учащает +опрос внешнего сервиса во столько раз, во сколько задержка опроса длиннее +секунды. -#### Scenario: Отказавшая задача ждёт +#### Scenario: Отказавшая запись ждёт -- **GIVEN** задача отказала на шаге конвейера +- **GIVEN** запись отказала на шаге конвейера - **WHEN** захват приходит раньше конца её паузы -- **THEN** задача ему не выдаётся +- **THEN** запись ему не выдаётся #### Scenario: Вторая пауза длиннее первой -- **GIVEN** задача отказала дважды подряд +- **GIVEN** запись отказала дважды подряд - **WHEN** сравнивают паузу после второго отказа с паузой после первого - **THEN** вторая длиннее -#### Scenario: Ожидание операции не учащается и не тратит попыток +#### Scenario: Ожидание операции не учащается и не тратит отказов - **GIVEN** внешняя операция распознавания ещё идёт -- **WHEN** шаг проверки отрабатывает подряд несколько раз +- **WHEN** шаг опроса отрабатывает подряд несколько раз - **THEN** задержка до следующей проверки каждый раз одна и та же -- **AND** число попыток задачи не растёт +- **AND** число отказов записи не растёт ### Requirement: Недоставленный ответ не роняет шаг -Шаг конвейера SHALL доводить задачу до достигнутого состояния, когда ответ +Шаг конвейера SHALL доводить запись до достигнутого рубежа, когда ответ отправителю доставить не удалось, и MUST не считать недоставку отказом шага. Недоставка MUST быть записана в журнал владельца, MUST нести идентификатор -задачи, MUST называть причину и MUST считаться отдельной метрикой с причиной +записи, MUST называть причину и MUST считаться отдельной метрикой с причиной меткой. Причин у недоставки две, и исход у них общий: **вход отправителя не поднят** — -задача заведена прошлым запуском, а сервис поднялся без этого входа; и **адресат -у задачи не назван** — источником значится Telegram, а чата в задаче нет. +запись заведена прошлым запуском, а сервис поднялся без этого входа; и **адресат +у записи не назван** — источником значится Telegram, а чата в записи нет. Уровень записи MUST различать эти причины. Неподнятый вход — объявленный режим, и его уровень «может стать проблемой». Неназванный адресат — симптом порчи -записи: у задачи из Telegram чат есть всегда, и пропасть он может только от +записи: у записи из Telegram чат есть всегда, и пропасть он может только от дефекта, самый коварный источник которого назван инвариантом проекта про колонки очереди. Один уровень на обе причины утопил бы этот сигнал в потоке штатных записей о ненастроенном боте. Общий исход — не упрощение, а следствие момента: ответ уходит **после** того, как -достигнутое состояние сохранено. Работа к этой минуте сделана, и объявленный -отказ засчитался бы воркеру сбоем и лёг бы владельцу записью отказа — то есть -соврал бы про исход дважды. Повтор делу не помогает: ни бот, ни адресат от -ожидания не появятся. Поэтому задача остаётся в достигнутом состоянии, в повтор -не уходит и в `failed` не переводится, а причина недоставки живёт в записи -журнала, а не в состоянии задачи. +достигнутый рубеж сохранён. Работа к этой минуте сделана, и объявленный отказ +засчитался бы воркеру сбоем и лёг бы владельцу записью отказа — то есть соврал бы +про исход дважды. Повтор делу не помогает: ни бот, ни адресат от ожидания не +появятся. Поэтому запись остаётся на достигнутом рубеже, в повтор не уходит и +**признака остановки не получает**, а причина недоставки живёт в записи журнала, +а не в рубеже записи. -Идентификатор задачи в записи обязателен: без него владелец видит, что ответ не -ушёл, но не может найти, чей. Текст расшифровки и сообщение отправителя в эту -запись MUST не попадать — приватность содержимого записи требование не +То же MUST относиться к недоставке сообщения об **остановке**: остановка уже +сохранена, и недоставка её MUST не отменять. + +Идентификатор записи в этой строке обязателен: без него владелец видит, что +ответ не ушёл, но не может найти, чей. Текст расшифровки и сообщение отправителя +в эту запись MUST не попадать — приватность содержимого записи требование не ослабляет. Отложенной доставки это требование не заводит: ответ, не ушедший сегодня, не @@ -299,60 +303,410 @@ MUST расти с числом её попыток до объявленног #### Scenario: Вход отправителя не поднят -- **GIVEN** задача принята входом Telegram прошлым запуском сервиса +- **GIVEN** запись принята входом Telegram прошлым запуском сервиса - **AND** сервис поднялся без этого входа - **WHEN** шаг конвейера доходит до ответа отправителю - **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем -- **AND** задача остаётся в достигнутом состоянии, в повтор не уходит и в - `failed` не переводится +- **AND** запись остаётся на достигнутом рубеже, в повтор не уходит и признака + остановки не получает - **AND** в журнале есть запись уровня `WARN` о недоставке с идентификатором - задачи и причиной + записи и причиной - **AND** счётчик недоставленных ответов вырос с этой причиной меткой - **AND** ни текста расшифровки, ни сообщения отправителя в этой записи нет -#### Scenario: Адресат у задачи не назван +#### Scenario: Адресат у записи не назван -- **GIVEN** у задачи источником значится Telegram, а чат не назван +- **GIVEN** у записи источником значится Telegram, а чат не назван - **WHEN** шаг конвейера доходит до ответа отправителю - **THEN** шаг завершается без отказа, и воркер не считает прогон сбоем -- **AND** задача остаётся в достигнутом состоянии +- **AND** запись остаётся на достигнутом рубеже - **AND** в журнале есть запись уровня `ERROR` о недоставке с идентификатором - задачи и причиной: неназванный адресат — симптом порчи записи + записи и причиной: неназванный адресат — симптом порчи записи + +#### Scenario: Не доехало сообщение об остановке + +- **GIVEN** запись остановлена признаком +- **AND** вход отправителя не поднят +- **WHEN** шаг доходит до ответа отправителю +- **THEN** признак остановки у записи остаётся +- **AND** в журнале есть запись о недоставке с идентификатором записи и причиной #### Scenario: Отвечать некуда, потому что запись пришла не из Telegram -- **GIVEN** задача принята по HTTP +- **GIVEN** запись принята по HTTP - **WHEN** шаг конвейера доходит до ответа отправителю - **THEN** шаг завершается без отказа и без записи о недоставке ### Requirement: Выборка воркера владельцем не сужается -Воркер SHALL брать задачи всех владельцев подряд и MUST не учитывать владельца -при выборе очередной задачи. Задача без владельца — принятая ботом — MUST +Воркер SHALL брать записи всех владельцев подряд и MUST не учитывать владельца +при выборе очередной записи. Запись без владельца — принятая ботом — MUST обрабатываться наравне с прочими. Владелец решает, кому запись показывать, а не кому её считать. Сужение выборки владельцем остановило бы расшифровку записей бота вовсе, а записи остальных поставило бы в зависимость от того, кто первым завёл учётную запись. -Владелец задачи MUST переживать работу конвейера: шаг, сохраняющий свой +Владелец записи MUST переживать работу конвейера: шаг, сохраняющий свой результат, владельца не трогает и не затирает. -#### Scenario: Задачи двух владельцев проходят одним воркером +#### Scenario: Записи двух владельцев проходят одним воркером -- **GIVEN** заведены задачи двух разных владельцев в одном состоянии -- **WHEN** воркер забирает задачи этого состояния +- **GIVEN** заведены записи двух разных владельцев на одном рубеже +- **WHEN** воркер забирает работу - **THEN** ему достаются обе, в порядке заведения -#### Scenario: Задача без владельца обрабатывается +#### Scenario: Запись без владельца обрабатывается -- **GIVEN** заведена задача, принятая ботом, — без владельца -- **WHEN** воркер забирает задачи её состояния +- **GIVEN** заведена запись, принятая ботом, — без владельца +- **WHEN** воркер забирает работу - **THEN** она достаётся ему наравне с прочими #### Scenario: Шаг конвейера владельца не затирает -- **GIVEN** задача с владельцем прошла шаг конвейера +- **GIVEN** запись с владельцем прошла шаг конвейера - **WHEN** шаг сохраняет свой результат -- **THEN** владелец задачи остаётся прежним +- **THEN** владелец записи остаётся прежним + +### Requirement: Рубеж записи называет достигнутое + +Аудиозапись SHALL нести рубеж — состояние, называющее **достигнутое**, а не +предстоящее. Цепочка рубежей: `uploaded`, `normalized`, `submitted`, +`transcribed`, `done`. Какой шаг делать дальше, сервис MUST выбирать по рубежу +одним общим местом, а не тем, какой воркер пришёл за записью. + +Прежние состояния называли предстоящую работу (`created`, `converted`, +`transcribe`), и потому по состоянию нельзя было сказать, что с записью уже +сделано: продолжить с места остановки было не с чего. + +Конечный рубеж MUST зваться `done`. Доставка ответа отправителю в конвейер не +входит, и слово описывает пройденный конвейер, а не полученный человеком текст. + +Перечень рубежей, из которых запись берётся в работу, MUST выводиться из одного +объявления рубежа, а не перечисляться отдельно каждым потребителем. Рубеж, +забытый в отборе захвата, не выдаётся ни одному воркеру никогда, а пустой прогон +по инварианту проекта не пишется в журнал и не считается в метрику: запись +встала бы без единого следа. Потребителей у перечня больше двух — выбор шага, +отбор захвата, срок захвата, предел времени, закрытый перечень значений в схеме, +— и человеческая сверка между ними не механизируема. + +Записи на конечном рубеже MUST не браться в работу и MUST не подпадать под +предел времени в рубеже: `done` не ждёт работы, и стоять в нём запись будет +вечно по построению. + +#### Scenario: Рубеж называет сделанное + +- **GIVEN** запись прошла приведение к рабочему формату +- **WHEN** смотрят её рубеж +- **THEN** он называет приведение сделанным, а не предстоящим + +#### Scenario: Следующий шаг выбирается по рубежу + +- **GIVEN** запись стоит на рубеже приведения +- **WHEN** её берёт воркер +- **THEN** идёт отправка на распознавание, а не повторное приведение + +#### Scenario: Запись на конечном рубеже не берут и не останавливают + +- **GIVEN** запись стоит на конечном рубеже дольше любого предела +- **WHEN** приходит захват +- **THEN** запись ему не выдаётся +- **AND** признака остановки у неё не появляется + +### Requirement: Остановка записи — признак, а не рубеж + +Сервис SHALL останавливать запись отдельным признаком с причиной и MUST не +стирать при этом достигнутый рубеж. Признак MUST нести время остановки, причину +и машинный текст отказа. + +Снятие признака SHALL возвращать запись в работу **с того рубежа, где она +стояла**, и MUST сбрасывать число отказов, паузу **и время входа в рубеж**. +Время входа сбрасывается по той же причине, что и остальные сторожа: запись, +простоявшая остановленной дольше предела, иначе останавливалась бы снова первым +же захватом, и перезапуск не работал бы вовсе. + +Прежние состояния отказа и смерти MUST не заводиться заново: обе причины +восстанавливаются одинаково — снятием признака, — и различие между ними +перестаёт быть структурным, оставаясь причиной остановки. Состояние, называющее +отказ, стирает достигнутый рубеж, и продолжение с места остановки становится +невозможным. + +**Способ вывести запись из выборки MUST быть один — этот признак.** Второго +признака, исключающего запись из работы помимо рубежа и паузы, MUST не +заводиться: два способа расходятся, и молчаливо теряется тот, который забыли +проверить. Условие отбора MUST не выводить запись из выборки молча — запись, +переставшая браться в работу, обязана нести признак остановки с причиной. + +Остановку MUST ставить тот, кто запись захватил. Перевод принадлежит одному +месту: условие отбора, молча пропускающее запись мимо выборки, оставило бы её +без следа. + +Остановленная запись MUST не выдаваться захвату. + +#### Scenario: Остановленная запись продолжает с места остановки + +- **GIVEN** шаг остановил запись на рубеже приведения +- **WHEN** признак остановки снимают +- **THEN** следующим идёт отправка на распознавание, а не повторное приведение + +#### Scenario: Остановленная запись не выдаётся захвату + +- **GIVEN** у записи стоит признак остановки +- **WHEN** за её рубежом приходит захват +- **THEN** запись ему не выдаётся + +#### Scenario: Снятие признака сбрасывает всех сторожей + +- **GIVEN** запись остановлена с накопленными отказами и паузой +- **AND** остановленной она простояла дольше предела времени в рубеже +- **WHEN** признак остановки снимают +- **THEN** число отказов, пауза и время входа в рубеж сброшены +- **AND** ближайший захват выдаёт запись, а не останавливает её снова + +### Requirement: Всякая остановка сообщает отправителю + +Остановка записи по любой причине SHALL сообщать отправителю о неудаче ровно +так же, как сообщает о ней отказ шага, и MUST быть видна владельцу сервиса +записью в журнале. + +Требование стоит на инварианте проекта «Принятая запись не теряется молча»: +инвариант допускает два исхода — запись пригодна к повтору либо об отказе +сказано, — а остановленная запись захвату не выдаётся, значит первый исход +исключён. + +Причин остановки больше одной, и обязанность общая для всех: исчерпанные +отказы, застревание в рубеже, приговор шага. Обязанность, записанная у одной +причины, у остальных читалась бы как снятая. + +Ответ уходит **после** того, как признак остановки сохранён, и недоставка этого +ответа MUST не отменять остановку: её нормирует требование «Недоставленный ответ +не роняет шаг». + +#### Scenario: Остановка по отказам сообщает отправителю + +- **GIVEN** запись остановлена по исчерпании отказов +- **WHEN** шаг доходит до ответа отправителю +- **THEN** отправитель получает сообщение о неудаче + +#### Scenario: Остановка по времени сообщает отправителю + +- **GIVEN** запись остановлена по пределу времени в рубеже +- **WHEN** шаг доходит до ответа отправителю +- **THEN** отправитель получает сообщение о неудаче +- **AND** в журнале владельца есть запись об остановке с причиной + +### Requirement: Время в рубеже ограничено + +У аудиозаписи SHALL быть время входа в рубеж, и оно MUST ставиться только при +смене рубежа и при возврате записи в работу. Запись, простоявшая в рубеже дольше +предела, MUST останавливаться признаком с причиной «застряла». + +Пределов MUST быть два, и граница проходит по тому, **чью работу ждём**: своя +работа — час, ожидание чужой операции — сутки. Одно общее число пришлось бы +мерить по самому долгому, и застрявшее приведение стояло бы сутки; число на +каждый рубеж назвало бы разными вещи, различающиеся только исполнителем. Сколько +идёт распознавание долгой записи, сервис не мерил, поэтому у чужой работы ошибка +идёт в сторону долгого: ложная остановка хуже поздней. Оба числа MUST лежать в +настройках. + +Сторож этот ловит **зависание**, а не долгую работу, и час у своей работы меньше +времени, которое многочасовая запись занимает на приведении. Цена решения +названа прямо: длинная запись, отказавшая один раз и ждущая повтора дольше часа, +будет остановлена как застрявшая. Цена ограничена тем, что остановка обратима — +снятие признака возвращает запись на её рубеж, — и тем, что живой шаг проверяется +по самому процессу. Решение владельца 2026-08-14. + +Откладывание опроса MUST не двигать время входа в рубеж и MUST не сдвигать этот +предел. Иначе запись, чью чужую операцию опрашивают раз в несколько секунд, +никогда не достигнет предела, и застревание останется незамеченным. + +Остановка по этому пределу MUST ничего не терять: идентификатор чужой операции +остаётся в строке попытки распознавания, и снятие признака возобновляет опрос +той же операции, а не заводит вторую. + +#### Scenario: Сотня откладываний не двигает отсчёт + +- **GIVEN** запись стоит на рубеже отправки, и чужая операция ещё идёт +- **WHEN** опрос откладывается сотню раз подряд +- **THEN** время входа в рубеж не изменилось +- **AND** отсчёт до предела не обнулился + +#### Scenario: Предел достигнут + +- **GIVEN** запись простояла в рубеже дольше своего предела +- **WHEN** за ней приходит захват +- **THEN** запись получает признак остановки с причиной «застряла» + +#### Scenario: Возобновление опроса не заводит вторую операцию + +- **GIVEN** запись остановлена по пределу на рубеже отправки +- **WHEN** признак остановки снимают +- **THEN** опрос идёт по прежнему идентификатору операции +- **AND** новая операция у провайдера не заводится + +### Requirement: Откладывание не является переходом + +Сервис SHALL различать переход на новый рубеж и откладывание работы над +записью. Откладывание MUST ставить паузу и снимать захват, MUST не трогать ни +рубеж, ни время входа в него, и MUST не считаться отказом: ожидание чужой +операции отказом не является, поэтому число отказов оно MUST обнулять. + +Сегодня шаг опроса зовёт переход с **тем же** состоянием, и мнимость этого +перехода обнуляет счётчик. Без разделения время входа в рубеж сбрасывалось бы на +каждом опросе и повторило бы ровно тот промах, ради которого заводится. + +#### Scenario: Откладывание не двигает рубеж + +- **GIVEN** шаг опроса увидел, что чужая операция ещё идёт +- **WHEN** он откладывает работу +- **THEN** рубеж записи прежний, и время входа в него прежнее +- **AND** захват с записи снят, а пауза поставлена + +### Requirement: Шаг с внешней оплатой проверяет сделанное + +Шаг, чьё повторение оплачивается наружу, SHALL проверять, не сделана ли работа +уже, и MUST не делать её второй раз. Проверка MUST идти по наблюдаемому признаку +присутствия результата, а не по сверке содержимого хешем: у составного объекта +во внешнем хранилище признак целостности не равен отпечатку содержимого. + +Признак MUST записываться прежде, чем оплачиваемое обращение считается +состоявшимся: строка попытки распознавания заводится до обращения к провайдеру, +и повторный шаг начинает с проверки, не заведена ли операция. + +Полной защиты от обрыва процесса между ответом провайдера и записью признака +требование не даёт и дать не может: жёсткая остановка контейнера не оставляет +места ни одной записи. Это остаточный риск, названный в дизайне, а не норма: +норма, обязывающая к недостижимому, не пишется. + +#### Scenario: Работа уже сделана + +- **GIVEN** результат оплачиваемого шага уже на месте, и признак его записан +- **WHEN** шаг повторяется +- **THEN** внешнее обращение не повторяется + +### Requirement: Число воркеров задаётся настройкой + +Сервис SHALL брать число рабочих потоков конвейера из настроек, а сами потоки +MUST не быть привязаны к отдельному шагу: каждый берёт любую подходящую запись и +выбирает шаг по её рубежу. Поведение записи MUST не зависеть от числа потоков. + +Ноль MUST быть законным значением: сервис поднимается, записи принимаются и не +двигаются. Это режим, а не поломка. + +Счётчик работы воркера MUST различать шаги: метка счётчика MUST нести рубеж, с +которого запись взята, а не имя или номер потока. У одинаковых потоков имя +перестаёт что-либо значить, а счётчик отказов — единственный сигнал, по которому +владелец сервиса замечает поломку; без разреза по шагу «падает приведение» и +«падает распознавание» становятся неразличимы. + +Опрос чужой операции MUST оставаться работой очереди, а не отдельного +смотрителя: очередь даёт ему неделимость захвата и возврат брошенного даром, а +единственный смотритель умирает молча и уносит с собой целый класс записей. + +#### Scenario: Запись доходит при одном потоке и при нескольких + +- **GIVEN** число потоков конвейера равно одному +- **WHEN** запись проходит конвейер +- **THEN** она доходит до конечного рубежа +- **AND** при числе потоков больше одного исход тот же + +#### Scenario: Потоков нет вовсе + +- **GIVEN** число потоков конвейера равно нулю +- **WHEN** запись принимают +- **THEN** сервис принимает её и не теряет +- **AND** запись остаётся на первом рубеже + +#### Scenario: Отказ виден с разрезом по шагу + +- **GIVEN** шаг приведения отказал +- **WHEN** наблюдатель читает счётчик работы воркера +- **THEN** отказ засчитан с меткой рубежа приведения + +### Requirement: Журнал событий записи пишется на смену рубежа + +Сервис SHALL вести журнал событий аудиозаписи и MUST писать в него строку на +смену рубежа, на остановку и на снятие остановки. Строка MUST называть источник +события — шаг конвейера или человека, — сам шаг, исход и длительность. + +Журнал MUST не писаться на каждое откладывание опроса: часовая запись дала бы +сотни строк ни о чём. + +Ни один шаг конвейера MUST не читать этот журнал, чтобы решить, что делать +дальше: решение принимается по рубежу записи, и второй источник решения +разошёлся бы с первым молча. + +Содержимое записи в журнал событий MUST не попадать — инвариант приватности +действует здесь наравне с журналом сервиса. + +#### Scenario: Смена рубежа записана + +- **GIVEN** шаг конвейера довёл запись до нового рубежа +- **WHEN** смотрят журнал событий этой записи +- **THEN** в нём есть строка с шагом, исходом и длительностью + +#### Scenario: Откладывание строки не пишет + +- **GIVEN** опрос чужой операции откладывается многократно +- **WHEN** смотрят журнал событий записи +- **THEN** строк об откладываниях в нём нет + +#### Scenario: Перезапуск человеком виден в журнале + +- **GIVEN** запись остановлена признаком +- **WHEN** человек снимает признак +- **THEN** в журнале событий есть строка с указанием, что это сделал человек + +### Requirement: Число отказов ограничивает повторы шага + +У аудиозаписи SHALL быть число отказов. Оно MUST расти при каждом захвате и MUST +возвращаться к нулю, когда шаг завершился без отказа либо отложил работу. Рост +при захвате, а не при отказе, засчитывает попытку и записи, брошенной на +середине: шаг, уносящий с собой процесс, до объявления отказа не доходит +никогда. + +**Остановка сервиса отказом не считается.** Шаг, прерванный отменой по +собственной остановке сервиса, MUST возвращать число отказов назад и MUST не +выносить записи приговора: запись не виновата в том, что нас перезапустили, и +несколько выкладок подряд иначе останавливают здоровую многочасовую запись с +приговором «отказы исчерпаны». Всякая другая причина, по которой шаг не дошёл до +объявления исхода, отказ тратит. + +Запись, захваченная с числом отказов сверх заданного предела, MUST +останавливаться признаком тем, кто её захватил, и MUST не отдаваться шагу в +работу. Об этой остановке отправителю сообщается наравне с прочими — норму +держит требование «Всякая остановка сообщает отправителю». + +Этот сторож MUST отвечать только за повторы внутри шага. Время, проведённое +записью в рубеже, MUST мериться отдельным сторожем: одно число не справляется ни +с одной из двух обязанностей — опрос, вернувший «ещё в работе», обнуляет его, и +зависшая чужая операция опрашивается вечно, а не обнулял бы — убивал бы здоровую +запись. + +#### Scenario: Запись отказывает на каждой попытке + +- **GIVEN** шаг конвейера отказывает на каждой попытке +- **WHEN** запись проходит заданное число отказов +- **THEN** у неё появляется признак остановки +- **AND** следующий захват её не выдаёт +- **AND** отправитель получает сообщение о неудаче + +#### Scenario: Шаг уносит процесс, не объявив отказа + +- **GIVEN** шаг конвейера обрывается вместе с процессом на каждой попытке +- **WHEN** запись захватывается снова заданное число раз +- **THEN** у неё появляется признак остановки + +#### Scenario: Остановка сервиса отказа не тратит + +- **GIVEN** шаг работает над записью +- **WHEN** сервис останавливают, и шаг прерывается отменой +- **THEN** число отказов записи прежнее +- **AND** признака остановки у записи не появляется + +#### Scenario: Прошедшая запись отказов не копит + +- **GIVEN** запись прошла подряд несколько рубежей без единого отказа +- **WHEN** смотрят её число отказов +- **THEN** оно не приблизилось к пределу diff --git a/openspec/specs/recognition/spec.md b/openspec/specs/recognition/spec.md new file mode 100644 index 0000000..4e3c223 --- /dev/null +++ b/openspec/specs/recognition/spec.md @@ -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** повторная отправка той же записи не заводится + diff --git a/openspec/specs/storage/spec.md b/openspec/specs/storage/spec.md index d2a1842..6dddad5 100644 --- a/openspec/specs/storage/spec.md +++ b/openspec/specs/storage/spec.md @@ -2,14 +2,17 @@ ## Purpose -Где живут запись, её метаданные и её файл: раскладка каталога данных, приведение -схемы при подъёме, отдача файла ссылкой по токену, собственная поверхность -хранилища и панель владельца. +Где живут аудиозапись, её приложения и её файлы: раскладка каталога данных, +приведение схемы при подъёме, отдача файла ссылкой по токену, собственная +поверхность хранилища и панель владельца. -Приём и опрос готовности нормирует `intake`, вход и сессию — `access`. -Сознательно не описаны: перенос прежних данных — его нет по решению задачи -`pocketbase-storage`; удаление записей и файлов — сервис объявлен архивом -2026-08-11, а удаление приносит задача `delete-record`. +Приём и опрос готовности нормирует `intake`, вход и сессию — `access`, попытку +распознавания у внешнего провайдера — `recognition`. + +Сознательно не описаны: перенос прежних данных — его нет ни по решению задачи +`pocketbase-storage`, ни по решению владельца 2026-08-14, которым прежние записи +удалены вместе с остановкой сервиса; удаление записей и файлов — сервис объявлен +архивом 2026-08-11, а удаление приносит задача `delete-record`. ## Requirements ### Requirement: Сервис поднимается на чистом каталоге данных @@ -208,22 +211,24 @@ MUST завести свою схему и принимать записи об ### Requirement: Владелец видит записи в панели -Сервис SHALL давать владельцу панель, где задача видна строкой, отбирается по -своему идентификатору и правится, а её файл слушается и скачивается. +Сервис SHALL давать владельцу панель, где аудиозапись видна строкой, отбирается +по своему идентификатору и правится, а её файлы слушаются и скачиваются. Панель MUST отдаваться тем же сервисом по своему адресу и MUST не требовать второго процесса. -Панель — вход в задачу наравне с конвейером, а не окно просмотра, и правка -состояния задачи в ней MUST подчиняться тем же правилам перехода, что и правка -из кода: служебные поля прошлого состояния — признак захвата, время захвата, -пауза, число попыток — MUST очищаться. Иначе владелец, вернувший мёртвую задачу в -работу, получит задачу, которая не выдаётся захвату до конца прежнего срока и -умирает от первого же отказа, — и не узнает об этом. +Панель — вход в запись наравне с конвейером, а не окно просмотра. Снятие +признака остановки в панели MUST возвращать запись в работу с сохранённого +рубежа и MUST очищать служебные поля прошлого захвата — признак захвата, срок +его протухания, паузу, число отказов — и MUST заново ставить время входа в +рубеж. Правка рубежа руками MUST делать то же самое. Иначе владелец, вернувший +запись в работу, получит запись, которая не выдаётся захвату до конца прежнего +срока, останавливается от первого же отказа или останавливается снова первым же +захватом по пределу времени, — и не узнает об этом. -Задача, заведённая в панели руками, MUST не уносить сервис: поля, без которых +Запись, заведённая в панели руками, MUST не уносить сервис: поля, без которых шаг конвейера не может работать, MUST быть обязательными в самой схеме, а -перечень состояний — закрытым. +перечень рубежей — закрытым. Панель разграничению доступа сервиса не подчиняется: вошедший в неё видит все записи, все файлы и всех пользователей разом. Закрывает её контур выкладки, а не @@ -231,18 +236,20 @@ MUST завести свою схему и принимать записи об #### Scenario: Принятая запись видна владельцу -- **GIVEN** запись принята и её задача заведена -- **WHEN** владелец отбирает задачи по идентификатору принятой -- **THEN** он видит её строкой со своим состоянием -- **AND** файл этой записи скачивается из той же строки +- **GIVEN** запись принята и заведена +- **WHEN** владелец отбирает записи по идентификатору принятой +- **THEN** он видит её строкой со своим рубежом +- **AND** её файл скачивается из той же строки -#### Scenario: Мёртвую задачу вернули в работу правкой в панели +#### Scenario: Остановленную запись вернули в работу правкой в панели -- **GIVEN** задача в состоянии «мертва» с исчерпанными попытками и признаком +- **GIVEN** запись остановлена признаком, с накопленными отказами и признаком прежнего захвата -- **WHEN** владелец меняет её состояние на рабочее -- **THEN** признак захвата, время захвата, пауза и число попыток очищены -- **AND** ближайший захват выдаёт задачу +- **AND** остановленной она простояла дольше предела времени в рубеже +- **WHEN** владелец снимает признак остановки +- **THEN** признак захвата, срок его протухания, пауза и число отказов очищены +- **AND** время входа в рубеж поставлено заново +- **AND** ближайший захват выдаёт запись с сохранённого рубежа ### Requirement: Пароль владельца от панели не лежит в конфигурации @@ -278,8 +285,8 @@ MUST завести свою схему и принимать записи об ### Requirement: Владелец задачи лежит связью с учётной записью -Хранилище SHALL держать владельца задачи расшифровки отдельной колонкой — связью -с учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец +Хранилище SHALL держать владельца аудиозаписи отдельной колонкой — связью с +учётной записью, — и эта колонка MUST не иметь умолчания: запись, чей владелец не назван, не достаётся никому по недосмотру схемы. Колонка MUST допускать пустое значение, и это решение с названной ценой: записи, @@ -287,35 +294,33 @@ MUST завести свою схему и принимать записи об записью сервис не ведёт. Обязательность для приёма по HTTP держит сама capability `intake`, а не схема. -Колонка приезжает **новым шагом схемы**: применённый шаг не переписывается. -Записей, заведённых до этого шага, сервис не переносит — проект заводится с -чистого листа. +Владелец MUST не назначаться и не меняться конвейером. #### Scenario: Колонка появляется на пустой базе - **WHEN** сервис поднимается на чистом каталоге данных -- **THEN** у таблицы задач есть колонка владельца +- **THEN** у аудиозаписи есть колонка владельца - **AND** умолчания у неё нет +#### Scenario: Конвейер владельца не назначает + +- **GIVEN** запись с владельцем прошла шаг конвейера +- **WHEN** смотрят её владельца +- **THEN** он прежний + ### Requirement: Файл записи сужается владельцем наравне с задачей Хранилище SHALL держать владельца и у файла записи — той же связью с учётной -записью, тем же шагом схемы, — и правило просмотра файлов MUST пускать к файлу -только его владельца. Прежнее правило пускало всякого узнанного, и знание -идентификатора файловой записи равнялось праву скачать чужое аудио. +записью, — и правило просмотра файлов MUST пускать к файлу только его владельца. -Без этого требования разграничение закрывает метаданные задачи и оставляет -открытым содержимое — то самое, что оно и заведено прятать. Хуже самой дыры была -бы отметка о закрытии: паспорт и модель угроз называют исполнителем этой работы -именно эту задачу, и слово «закрыто» скрыло бы открытый путь. - -Владелец файла MUST назначаться там же, где владелец задачи, — при приёме, из +Владелец файла MUST назначаться там же, где владелец записи, — при приёме, из предъявленной сессии, — и MUST оставаться пустым у файлов, заведённых конвейером для записи без владельца. -Ссылка на файл в задаче переставляется каждым шагом конвейера, поэтому владелец -файла MUST лежать своей колонкой, а не выводиться через задачу: исходная копия -после конвертации не связана с задачей ничем. +Ссылки на файлы у записи две — на принятую копию и на приведённую, — и обе живут +до конца, но владелец файла MUST по-прежнему лежать своей колонкой, а не +выводиться через запись: файл переживает свою запись, и заведённый шагом до +сохранения записи он остаётся с владельцем и без ссылки. Отказ наступает **на переходе по ссылке**, а не на выдаче токена файла: токен хранилище выдаёт на предъявителя, а не на файл, и о файле при выдаче не @@ -343,15 +348,21 @@ capability `intake`, а не схема. ### Requirement: Учётная запись с записями не удаляется -Хранилище SHALL отвергать удаление учётной записи, у которой остались задачи -расшифровки **либо файлы**. Отказ MUST называть причину, и MUST доезжать до +Хранилище SHALL отвергать удаление учётной записи, у которой остались +аудиозаписи **либо файлы**. Отказ MUST называть причину, и MUST доезжать до спрашивающего: хранилище пропускает наружу только свою ошибку роутера, а всякую другую подменяет сообщением про обязательную связь — подсказкой, по которой владелец панели пойдёт удалять записи руками. -Считаются обе коллекции с владельцем. Файл переживает свою задачу: шаг конвейера -заводит его до сохранения задачи, и потерянный захват оставляет файл с владельцем -и без ссылки. +Считаются **все** коллекции с колонкой владельца, и перечень их MUST жить одним +местом: коллекция, пропущенная в счёте, пропускает удаление вперёд, и наружу +приезжает не наш отказ с причиной, а подсказка библиотеки про обязательную связь +— та самая, по которой владелец панели пойдёт удалять записи руками. Сегодня их +три: аудиозаписи, файлы и словарь тем. + +Файл переживает свою запись: шаг конвейера заводит его до сохранения записи, и +потерянный захват оставляет файл с владельцем и без ссылки. Тема переживает её +так же: словарь принадлежит человеку, а не записи. Запрет MUST ставить сама сборка хранилища, а не вызывающий: сборка, забывшая его позвать, теряет защиту молча — и теряла, пока запрет вешался отдельной строкой @@ -361,12 +372,6 @@ capability `intake`, а не схема. удалить **свою** учётную запись запросом, так что запрет закрывает и публичную поверхность. -Требование заведено вместо прежнего «удаление не уносит задачи следом»: оно -выглядело выполненным, а на деле хранилище при выключенном каскаде **снимает -ссылку** — задачи остаются, но становятся ничьими, а ничья задача не достаётся -по API никому. Архив человека исчезал бы молча и восстановлению не подлежал: -прежнего владельца не остаётся нигде. - Цена требования названа прямо: владелец панели упирается в отказ, а способа удалить записи в сервисе пока нет вовсе — его приносит задача про удаление записи. До неё удаление учётной записи с записями невозможно, и это осознанный @@ -374,20 +379,175 @@ capability `intake`, а не схема. #### Scenario: Удаление учётной записи с записями отвергается -- **GIVEN** у учётной записи есть задачи расшифровки +- **GIVEN** у учётной записи есть аудиозаписи - **WHEN** её удаляют - **THEN** удаление не проходит, а отказ называет причину -- **AND** задачи и их владелец остаются прежними +- **AND** записи и их владелец остаются прежними #### Scenario: Учётная запись с одними файлами тоже не удаляется -- **GIVEN** у учётной записи остались файлы, но задач нет +- **GIVEN** у учётной записи остались файлы, но записей нет - **WHEN** её удаляют - **THEN** удаление не проходит, а владелец файлов остаётся прежним +#### Scenario: Учётная запись с одними темами тоже не удаляется + +- **GIVEN** у учётной записи остались темы словаря, но ни записей, ни файлов нет +- **WHEN** её удаляют +- **THEN** удаление не проходит, а отказ называет причину нашими словами + #### Scenario: Учётная запись без записей удаляется -- **GIVEN** у учётной записи нет ни задач, ни файлов +- **GIVEN** у учётной записи нет ни аудиозаписей, ни файлов, ни тем - **WHEN** её удаляют - **THEN** удаление проходит +### Requirement: Аудиозапись — центральная сущность хранилища + +Хранилище SHALL держать аудиозапись отдельной сущностью, а всё, что к ней +приложено, — отдельными строками со ссылками с записи. Приложениями считаются +файлы, тексты, структура реплик, темы, журнал событий и попытка распознавания. + +Поля, которыми распоряжается очередь — признак захвата, срок его протухания, +пауза, число отказов, время входа в рубеж, — MUST не соседствовать с содержимым +записи в одной строке настолько, чтобы чтение очереди тянуло содержимое: сегодня +расшифровка лежит колонкой той же строки и читается при каждом захвате. + +Запись MUST нести заголовок и краткое описание своими колонками: они читаются +вместе со списком, сотней штук разом. Расшифровка и вычитанный текст MUST лежать +отдельными строками: они читаются по открытию одной записи. + +#### Scenario: Список читается без содержимого + +- **GIVEN** у записи есть расшифровка +- **WHEN** читают запись ради её рубежа и заголовка +- **THEN** текст расшифровки при этом не читается + +### Requirement: Содержимое записи закрыто во всех коллекциях, где лежит + +Всякая коллекция, куда переезжает содержимое аудиозаписи, SHALL быть закрыта +наравне с самой записью: её правило просмотра MUST не открывать содержимое +никому, кроме владельца связанной записи, а поле, хранящее файл или вложение, +MUST быть помечено защищённым. + +Пока содержимое отдаётся собственным адресом сервиса, а не поверхностью +хранилища, правило просмотра MUST оставаться незаданным — то есть «только +владелец панели». Непустое правило открывает перечисление коллекции, и заводить +его раньше, чем появится потребитель, значит открывать поверхность впрок: +норму держит требование «Наружу хранилище отдаёт только то, что заказано». + +Требование распространяется на все коллекции приложений — тексты, структуру +реплик, попытку распознавания с её сохранённым ответом, журнал событий и темы, — +и заводится потому, что содержимое **переезжает** из одной строки в шесть. Норма +о защищённом поле файла сегодня написана про файл записи, а сырой ответ +распознавателя — это полный текст речи в другой коллекции: реализация, следующая +только прежней норме, завела бы поле с умолчанием библиотеки, и ссылка на него +отдавала бы расшифровку любому, кто её знает, без сессии. + +Ссылка на такое вложение MUST не попадать ни в журнал, ни в метку метрики, ни в +ответ отправителю — теми же словами, какими это нормировано для файла записи. + +Умолчание библиотеки здесь не годится ни в одном месте: незаданное правило +просмотра значит «только владелец панели» и отнимает содержимое у самого +владельца записи, а незащищённое поле файла отдаёт его всем. + +#### Scenario: Чужой сохранённый ответ не отдаётся + +- **GIVEN** запись принята одним вошедшим и прошла распознавание +- **WHEN** другой вошедший идёт по ссылке на сохранённый ответ провайдера +- **THEN** содержимого он не получает + +#### Scenario: Без сессии содержимое не отдаётся + +- **WHEN** ссылку на сохранённый ответ провайдера запрашивают без сессии +- **THEN** приходит отказ, а содержимого в ответе нет + +#### Scenario: Перечисление приложений закрыто + +- **WHEN** запрос без прав владельца просит список записей коллекции текстов +- **THEN** приходит отказ + +### Requirement: Ссылки на исходник и приведённую копию живут порознь + +Аудиозапись SHALL нести две отдельные ссылки на файлы — на принятую копию и на +копию, приведённую к рабочему формату, — и шаг конвейера MUST не переставлять +одну ссылку на свой результат. + +Сегодня ссылка одна, и её переставляет каждый шаг: у прошедшей конвейер записи +она ведёт на копию во внешнем хранилище, а принятого человеком файла не найти +ничем. Послушать загруженное нечем именно поэтому. + +Обе копии MUST оставаться доступными после того, как запись прошла конвейер. + +#### Scenario: После конвейера доступны обе копии + +- **GIVEN** запись прошла конвейер целиком +- **WHEN** смотрят её ссылки на файлы +- **THEN** ссылка на принятую копию и ссылка на приведённую заполнены +- **AND** обе открываются + +### Requirement: Тексты и структура лежат отдельно от записи + +Хранилище SHALL держать тексты записи отдельными строками, каждая со своим видом +текста, и структуру реплик — своей строкой. Запись MUST ссылаться на них, а не +хранить их колонками. + +Видов текста больше одного: сырая расшифровка и вычитанный текст. Колонкой на +каждый вид схема росла бы с каждым новым видом, а необратимый шаг схемы платится +за каждую такую колонку отдельно. + +**Приложение MUST быть уникально по паре «запись и вид»**, а структура — по паре +«запись и версия разбора». Шаг завершения пишет текст, структуру и сохранённый +ответ несколькими операциями и только потом двигает рубеж: прерванный на середине +и повторённый с прежнего рубежа, он завёл бы второй комплект строк, и вопрос +«какой текст отдавать человеку» стал бы вопросом порядка записи, а не состояния. + +Потребитель текста MUST называть **вид**, который берёт, а не брать последний +записанный: иначе исход зависит от порядка записи. Ответ опроса готовности берёт +сырую расшифровку — норму держит capability `intake`. + +#### Scenario: Расшифровка лежит своей строкой + +- **GIVEN** запись прошла распознавание +- **WHEN** смотрят, где лежит текст расшифровки +- **THEN** он лежит отдельной строкой, на которую запись ссылается + +#### Scenario: Повтор шага не заводит второй расшифровки + +- **GIVEN** шаг завершения записал расшифровку и оборвался до смены рубежа +- **WHEN** шаг повторяется с прежнего рубежа +- **THEN** строка расшифровки у записи одна + +### Requirement: Словарь тем ведётся по владельцу + +Хранилище SHALL держать темы отдельной коллекцией, и тема MUST быть уникальна в +паре «владелец и название»: словарь тем свой у каждого человека. У записи MUST +быть не больше пяти тем. + +Коллекцией, а не набором строк в записи, — потому что перечень тем человека +нужен целиком перед каждым обращением к модели, а собрать его из наборов строк +можно только перебором всех его записей. + +Потолок в пять тем MUST быть у самой записи: без него часовой разговор даёт два +десятка тем, и словарь распухает за неделю. + +Название темы выведено из содержимого записи, а перечень тем человека — слепок +того, о чём он вообще говорит. В журнал сервиса темы MUST не попадать наравне с +текстом расшифровки. + +Ни один шаг этого изменения тем не пишет и не читает: место заводится вперёд, +чтобы задача, считающая темы языковой моделью, не платила вторым необратимым +шагом схемы. Цена решения названа прямо — имена коллекции и её колонок +закрепляются раньше, чем известен их потребитель. + +#### Scenario: Тема одного человека не мешает теме другого + +- **GIVEN** у двух владельцев заведена тема с одинаковым названием +- **WHEN** смотрят словарь тем +- **THEN** это две разные темы, каждая своего владельца + +#### Scenario: Шестая тема не заводится + +- **WHEN** записи назначают шестую тему +- **THEN** назначение не проходит +