хранилище, файлы записей и очередь переведены на встроенную PocketBase

- записи, метаданные и файлы съехались под один каталог данных; появилась
  панель владельца, а gin, goqu, goose и требование CGO ушли
- захват задачи стал одним запросом с RETURNING; заведены число попыток,
  состояние dead и нарастающая пауза вместо признака is_error
- имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с
  идентификатором записи оно собирало бы ссылку на скачивание
This commit is contained in:
av
2026-08-12 08:31:59 +03:00
parent 09cedc4e61
commit 01cc31d45f
55 changed files with 5238 additions and 1235 deletions
+33 -18
View File
@@ -11,8 +11,9 @@
Сервис расшифровки аудио в текст. Принимает запись двумя входами — Telegram-бот и Сервис расшифровки аудио в текст. Принимает запись двумя входами — Telegram-бот и
HTTP API, — конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание HTTP API, — конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание
Yandex SpeechKit и возвращает текст туда, откуда пришла запись. Состояние задач Yandex SpeechKit и возвращает текст туда, откуда пришла запись. Состояние задач,
и метаданные файлов лежат в SQLite, файлы — на диске. метаданные и сами файлы лежат во встроенной PocketBase, и она же даёт владельцу
панель администратора.
Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст
руками не правит и в форматы документов не экспортирует, учётных записей не руками не правит и в форматы документов не экспортирует, учётных записей не
@@ -23,10 +24,10 @@ Yandex SpeechKit и возвращает текст туда, откуда пр
## Стек ## Стек
Go 1.24 (нужен CGO из-за `mattn/go-sqlite3`), gin, goqu, goose, SQLite, Go 1.25 (CGO не нужен), встроенная PocketBase — хранилище, файлы записей и
`go-telegram-bot-api`, `aws-sdk-go-v2` для Object Storage, gRPC-клиент Yandex панель администратора, — `go-telegram-bot-api`, `aws-sdk-go-v2` для Object
SpeechKit v3, Prometheus, `slog`. Сборка — Taskfile, образ — Docker, Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка —
выкладка — Ansible из `pet-project-server`. Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`.
## Инварианты ## Инварианты
@@ -40,8 +41,9 @@ SpeechKit v3, Prometheus, `slog`. Сборка — Taskfile, образ — Dock
пользователя и его сообщение в лог не пишутся — только длина и пользователя и его сообщение в лог не пишутся — только длина и
идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера. идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера.
**critical** **critical**
*Изъятие:* расширение — хвост после последней точки — в журнал попадает, потому *Изъятие:* расширение — хвост после последней точки — в журнал попадает
что стоит в собственном имени файла на диске, а путь логируется. Изъятие узкое собственным полем: по нему прослеживается путь записи. Имени файла в журнале
нет вовсе (инвариант ниже). Изъятие узкое
и кончается журналом: наружу, меткой метрики, расширение выходит только и кончается журналом: наружу, меткой метрики, расширение выходит только
приведённым к перечню известных форматов. Границу держит спека `intake`, приведённым к перечню известных форматов. Границу держит спека `intake`,
цена — [adr/ADR-2026-08-11-known-format-label.md](docs/adr/ADR-2026-08-11-known-format-label.md), цена — [adr/ADR-2026-08-11-known-format-label.md](docs/adr/ADR-2026-08-11-known-format-label.md),
@@ -58,15 +60,28 @@ SpeechKit v3, Prometheus, `slog`. Сборка — Taskfile, образ — Dock
логируется, не считается в метрику и не поднимает уровень. Нарушение даёт логируется, не считается в метрику и не поднимает уровень. Нарушение даёт
запись раз в секунду на каждый воркер. **major** запись раз в секунду на каждый воркер. **major**
- **Миграция, уехавшая на сервер, не переписывается.** Изменение — только новым - **Миграция, уехавшая на сервер, не переписывается.** Изменение — только новым
файлом. Необратимо: goose считает применённую версию по номеру. **critical** файлом шага. Необратимо: хранилище считает применённое по имени файла.
- **Новая колонка правится во всех четырёх местах** репозитория SQLite — **critical**
`Create`, `Save`, `GetByID`, `FindAndAcquire`. Компилятор расхождение не - **Имя файла в хранилище задаёт сервис, а в журнал не идёт.** Умолчание
поймает, а проявится оно как потерянное при сохранении поле. **major** PocketBase строит имя из имени, данного отправителем, — оно не применяется.
Само имя — последняя часть ссылки `/api/files/...`, поэтому в журнал пишется
расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой
записи. **critical**
- **Колонки очереди правятся в четырёх местах** пакета хранилища —
`applyToRecord`, `recordToJob`, константа `acquireColumns` и структура
`acquiredRow` с её `toJob`, — плюс шаг схемы. Компилятор видит два из них.
Колонка, забытая в паре `acquireColumns`/`acquiredRow`, приезжает из захвата
нулевой, и первый же `Save` пишет этот ноль поверх сохранённого значения:
поле теряется **только у задачи, попавшей к воркеру**. **major**
- **Результат пишет только держатель захвата.** Шаг, чей захват за время работы
достался другому, завершается без записи и без ответа отправителю. Иначе два
воркера пишут в одну задачу по очереди, а отправитель получает два ответа.
**major**
## Команды ## Команды
```bash ```bash
go build ./... # нужен CGO go build ./... # CGO не нужен
go test ./... go test ./...
go vet ./... go vet ./...
gofmt -l . gofmt -l .
@@ -118,10 +133,10 @@ task gate # весь набор проверок разом
## Запреты ## Запреты
- **Рабочую БД не трогать.** `data/transcriber.db` на сервере и его копии. - **Боевой каталог данных не трогать.** `data/` на сервере целиком: под ним и
Локальная база в `./data/` — своя, её ронять и пересоздавать можно свободно. база (`data/data.db`), и записи живых людей
- **Боевой каталог записей не трогать.** `data/files` на сервере: там лежат (`data/storage/<коллекция>/<запись>/`). Локальный каталог данных — свой, его
голосовые сообщения живых людей. ронять и пересоздавать можно свободно.
- **Боевым токеном бота не запускаться.** Второй процесс с тем же токеном - **Боевым токеном бота не запускаться.** Второй процесс с тем же токеном
перехватывает обновления у работающего, и пользователь теряет ответы. перехватывает обновления у работающего, и пользователь теряет ответы.
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage - **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
@@ -139,7 +154,7 @@ task gate # весь набор проверок разом
- **Основная ветка:** `master`. Коммиты идут в неё напрямую, веток и PR нет. - **Основная ветка:** `master`. Коммиты идут в неё напрямую, веток и PR нет.
- **Сообщение коммита** без трейлера `Co-Authored-By`. - **Сообщение коммита** без трейлера `Co-Authored-By`.
- **Необратимое** (спрашивается у человека всегда): применённая миграция, формат - **Необратимое** (спрашивается у человека всегда): применённая миграция, формат
файла на диске и раскладка `data/files`, публичный контракт HTTP API, имя файла на диске и раскладка каталога данных, публичный контракт HTTP API, имя
ключа конфига, любое действие с боевыми данными и с Yandex Cloud, ротация ключа конфига, любое действие с боевыми данными и с Yandex Cloud, ротация
секрета. секрета.
- **Что считается сломанным** — новый красный шаг гейта, которого не было до - **Что считается сломанным** — новый красный шаг гейта, которого не было до
+4 -7
View File
@@ -1,11 +1,8 @@
# Build stage # Build stage
FROM docker.io/library/golang:1.24-alpine AS build-env FROM docker.io/library/golang:1.25-alpine AS build-env
# Install build dependencies # Сборочных зависимостей нет: хранилище ходит в SQLite через modernc.org/sqlite,
RUN apk --no-cache add \ # и CGO больше не требуется.
build-base \
sqlite-dev \
&& rm -rf /var/cache/apk/*
# Set up the working directory # Set up the working directory
WORKDIR /app WORKDIR /app
@@ -20,7 +17,7 @@ RUN go mod download
COPY . . COPY . .
# Build the application # Build the application
RUN go build -o transcriber . RUN CGO_ENABLED=0 go build -o transcriber .
# ---------------- # ----------------
# Production stage # Production stage
+16 -27
View File
@@ -9,7 +9,7 @@
- Конвертация в ogg через ffmpeg - Конвертация в ogg через ffmpeg
- Распознавание речи через Yandex SpeechKit - Распознавание речи через Yandex SpeechKit
- Отслеживание статуса задач расшифровки - Отслеживание статуса задач расшифровки
- SQLite для хранения метаданных, метрики Prometheus - Встроенная PocketBase для метаданных, файлов и панели владельца; метрики Prometheus
## Технологии ## Технологии
@@ -17,9 +17,8 @@
- **Telegram**: go-telegram-bot-api - **Telegram**: go-telegram-bot-api
- **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3) - **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3)
- **Конвертация**: ffmpeg - **Конвертация**: ffmpeg
- **SQL Builder**: doug-martin/goqu - **Хранилище, файлы и панель**: встроенная PocketBase
- **Миграции БД**: pressly/goose - **База данных**: SQLite внутри PocketBase (через modernc.org/sqlite, CGO не нужен)
- **База данных**: SQLite
- **Метрики**: prometheus/client_golang - **Метрики**: prometheus/client_golang
## Установка и запуск ## Установка и запуск
@@ -97,36 +96,26 @@ transcriber/
│ ├── metaviewer/ffmpeg/ # Длительность аудио │ ├── metaviewer/ffmpeg/ # Длительность аудио
│ ├── recognizer/yandex/ # SpeechKit + Object Storage │ ├── recognizer/yandex/ # SpeechKit + Object Storage
│ ├── telegram/ # Отправка сообщений │ ├── telegram/ # Отправка сообщений
│ └── repo/sqlite/ # Репозитории │ └── repo/pocketbase/ # Репозитории, схема коллекций, правила панели
── migrations/ # Миграции goose, вшиты в бинарник через go:embed ── data/ # Каталог данных: база и файлы записей вместе
── data/ ── data.db # База хранилища (создаётся автоматически)
── files/ # Директория для сохранения файлов ── storage/ # Файлы записей в раскладке хранилища
└── transcriber.db # SQLite база данных (создается автоматически)
``` ```
## База данных ## Хранилище
Две таблицы, `files` и `transcribe_jobs`. Колонки, ключи, правило времени и Две коллекции, `files` и `transcribe_jobs`. Поля, ключи, правило времени и
идентификаторов, а также механика захвата задачи воркером — идентификаторов, а также механика захвата задачи воркером —
[docs/database.md](docs/database.md). [docs/database.md](docs/database.md). Панель владельца — по адресу `/_/` того же
порта; пароль от неё задаёт сам владелец по приглашению, которое сервис печатает
в журнал при первом запуске.
## Разработка ## Разработка
Миграции накатываются автоматически при старте сервиса — они вшиты в бинарник Схему двигают шаги миграций PocketBase на Go —
через `//go:embed`. Достаточно положить новый файл в `migrations/`. `internal/adapter/repo/pocketbase`. Непринятые шаги накатываются при подъёме
хранилища, прежде чем стартуют воркеры и сервер. Применённый шаг не
Создать файл миграции и накатить или откатить её вручную: переписывается: изменение — только новым файлом шага.
```bash
# Создание новой миграции
goose -dir migrations create migration_name sql
# Применение миграций
goose -dir migrations sqlite3 data/transcriber.db up
# Откат миграций
goose -dir migrations sqlite3 data/transcriber.db down
```
Проверки перед коммитом — одной командой: Проверки перед коммитом — одной командой:
+3 -6
View File
@@ -4,13 +4,10 @@ port = 8080
shutdown_timeout = 5 shutdown_timeout = 5
force_shutdown_timeout = 20 force_shutdown_timeout = 20
# Database configuration # Storage configuration
[database] # Единственный каталог данных: под ним лежат и база, и файлы записей.
path = "data/transcriber.db"
# File storage configuration
[storage] [storage]
path = "data/files" data_dir = "data"
# Yandex Cloud Configuration # Yandex Cloud Configuration
[yandex] [yandex]
@@ -0,0 +1,63 @@
# Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md](../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md),
раздел «Поле файла не помечаем защищённым, но ссылка не уезжает в журнал»
## Решение
Поле файла в хранилище **не помечается защищённым**: ссылка
`/api/files/<коллекция>/<запись>/<имя>` работает без токена, и право пройти по
ней даёт знание самой ссылки. Взамен имя файла в хранилище **не пишется в журнал
ни в каком виде** — ни на успешном пути, ни в тексте отказа.
## Почему
Очевидный подход к файлам, отдаваемым в интернет, — закрыть их: у хранилища для
этого есть пометка «защищённое поле», и тогда файл отдаётся только по отдельному
файловому токену. Мы от неё отказываемся, и цитата из источника называет причину:
> Защищённое поле требует отдельного файлового токена. Не помечаем: сегодня право
> прочитать задачу даёт знание её идентификатора, и файл встаёт вровень с
> `GET /api/status/:id`, а не ниже.
Отказ дешёв ровно до тех пор, пока ссылку неоткуда взять. Перевод хранилища это
условие сломал, и вот чем:
> Изъятие выписано под путь на диске: `data/files/<uuid>.ogg` читателю журнала
> бесполезен. После перевода имя файла в хранилище — это последняя часть ссылки
> `/api/files/...`, по которой запись скачивает кто угодно; строка журнала стала
> бы бессрочным ключом к чужому аудио.
Путь был построен ревью и прогнан: отказ чтения из хранилища нёс ключ файла
целиком, строка уходила в журнал, а анонимный запрос по собранному адресу
отвечал `200` с телом записи. Второй путь шёл через отказ выгрузки в Object
Storage — тот несёт полный URL объекта.
Отсюда вторая половина решения, без которой первая недопустима: **отказы
обрываются**. Наружу идёт свой текст с идентификатором записи, а чужая цепочка
`%w` — нет. В журнал приёма вместо имени идёт расширение собственным полем;
прослеживаемость от этого не страдает.
Запись попадает в журнал как **намеренный отказ от очевидного подхода**: закрыть
файлы токеном предложат снова, и без записанной причины предложение выглядит
бесплатным.
## Последствия
- `+` ссылка работает без токена, и приёмка проверяется обычным запросом; будущее
приложение получает файл без отдельного механизма выдачи токенов.
- `+` изъятие из инварианта приватности не расширилось: в журнале по-прежнему
только расширение, а не имя.
- `` ссылка, единожды утёкшая, работает бессрочно: отзыва у неё нет, а файлы не
удаляются вовсе. Утечка возможна не только журналом — любой будущий экран,
показывающий ссылку, наследует это свойство.
- `` появилась норма, которую держит не построение, а внимание: всякий новый
отказ хранилища надо обрывать руками. Норму сторожат требование capability
`storage` и проверка журнала, но компилятор — нет.
- `` диагностируемость отказов упала: обрывая цепочку, мы теряем причину. У
выгрузки в Object Storage это смягчено — сохраняется класс отказа SDK
(`AccessDenied`, `NoSuchBucket`), в котором адреса не бывает.
- Решение действует до разграничения доступа: задачи `oidc-login` и
`record-ownership` меняют условие, и тогда пометку стоит пересмотреть новой
записью.
@@ -0,0 +1,50 @@
# Каталог данных задаётся одним ключом `[storage] data_dir`
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md](../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md),
раздел «Ключи конфигурации: два пути заменяются одним каталогом»
## Решение
Ключи `[database] path` и `[storage] path` уходят. Вместо них — один
`[storage] data_dir` со значением `data`: база и файлы записей лежат под одним
каталогом, и по-другому хранилище не умеет.
Выбор сделан человеком 2026-08-12 из трёх названных вариантов.
## Почему
Имя ключа конфигурации проект объявил необратимым
([../../CLAUDE.md](../../CLAUDE.md), «Работа»): переименование правится не в
одном файле, а в конфигурации на сервере и в выкладке, и молча ломает запуск.
Поэтому выбор ушёл человеку, а не был принят по ходу.
Цитата источника о цене каждого варианта:
> - `[storage] data_dir` — **выбрано**. Ключ назван по назначению, как названы и
> сегодняшние; смена библиотеки через год имени не тронет. Слово `storage` при
> этом уже занято capability, но в конфигурации оно значит ровно то же — где
> лежат данные;
> - `[pocketbase] data_dir` — прямее всего читается тем, кто знает библиотеку, и
> вписывает имя поставщика в необратимый ключ. Смена библиотеки потребует
> второго необратимого переименования;
> - `[data] dir` — короче и нейтральнее всех, но `data` в проекте уже значит
> каталог на диске, и секция с таким именем читается как «настройки каталога»,
> а не «настройки хранилища».
Запись попадает в журнал по **дорогому откату**: переименование ключа стоит
правки конфигурации на сервере и в выкладке, а ошибка проявляется отказом старта.
## Последствия
- `+` имя ключа не называет поставщика, и смена библиотеки хранилища второго
необратимого переименования не потребует.
- `+` каталог данных один, и запрет «боевой каталог не трогать» покрывает и базу,
и записи одной строкой.
- `` слово `storage` в проекте теперь значит три вещи: capability, само
хранилище и секцию конфигурации. Поле записи о файле от этого переименовано в
`location` — чтобы смыслов было три, а не четыре.
- `` прежние конфигурации несовместимы: сервис на старом `config.toml`
поднимется на умолчании `data`, а не на прежних путях. Данные при этом не
переносятся по решению задачи, так что цена нулевая ровно сейчас и была бы не
нулевой при переносе.
+2
View File
@@ -32,6 +32,8 @@
| Дата | Запись | Статус | | Дата | Запись | Статус |
| --- | --- | --- | | --- | --- | --- |
| 2026-08-12 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](ADR-2026-08-12-file-link-open-but-not-logged.md) | |
| 2026-08-12 | [Каталог данных задаётся одним ключом `[storage] data_dir`](ADR-2026-08-12-single-data-dir-config-key.md) | |
| 2026-08-11 | [Границу распознавания доменного признака держит норма, а не код](ADR-2026-08-11-domain-marker-boundary-by-norm.md) | | | 2026-08-11 | [Границу распознавания доменного признака держит норма, а не код](ADR-2026-08-11-domain-marker-boundary-by-norm.md) | |
| 2026-08-11 | [Отказ, который решено не проверять, объявляется поимённо](ADR-2026-08-11-errcheck-check-blank.md) | | | 2026-08-11 | [Отказ, который решено не проверять, объявляется поимённо](ADR-2026-08-11-errcheck-check-blank.md) | |
| 2026-08-11 | [Наружу расширение выходит только приведённым к перечню](ADR-2026-08-11-known-format-label.md) | | | 2026-08-11 | [Наружу расширение выходит только приведённым к перечню](ADR-2026-08-11-known-format-label.md) | |
+35 -36
View File
@@ -8,16 +8,19 @@
[passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из [passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из
этого ещё не решено — в разделе «Открытые вопросы». этого ещё не решено — в разделе «Открытые вопросы».
Заведены две capability, и каждая описана частично: Заведены три capability:
- [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: его - [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: его
нормируют проверки, написанные задачей `http-handler-tests-never-green` нормируют проверки, написанные задачей `http-handler-tests-never-green`
2026-08-11; 2026-08-11;
- [pipeline](../openspec/specs/pipeline/spec.md) — **только пустой прогон - [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
воркера**: задача `errors-as-instead-of-typecast` 2026-08-11. Переходы задачи и срок его протухания, число попыток, состояние «мертва» и пауза перед
состояний, захват и срок его протухания, отмена контекста посреди шага в неё повтором: задачи `errors-as-instead-of-typecast` 2026-08-11 и
**не** переехали и остаются долгом; что именно не описано, перечисляет раздел `pocketbase-storage` 2026-08-12. Переходы состояний и отмена контекста посреди шага остаются
`Purpose` самой спеки. долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
2026-08-12.
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в
коде. Задача, которая его трогает, дописывает спеку своей capability. коде. Задача, которая его трогает, дописывает спеку своей capability.
@@ -26,17 +29,16 @@
- **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и - **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и
делят одну базу. Отдельного воркер-процесса нет намеренно. делят одну базу. Отдельного воркер-процесса нет намеренно.
- **Очередь таблицей.** Состояние задачи лежит в SQLite, воркер забирает работу - **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища, воркер
запросом с захватом. Внешний брокер не заводим: нагрузка — единицы записей в забирает работу одним запросом с захватом. Внешний брокер не заводим: нагрузка
день (оценка владельца, не замер). Готовую библиотеку очереди тоже не заводим — — единицы записей в день (оценка владельца, не замер). Готовую библиотеку
решено 2026-08-11, очереди тоже не заводим — решено 2026-08-11,
[ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение [ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение
кандидатов в [research/job-queue.md](research/job-queue.md). кандидатов в [research/job-queue.md](research/job-queue.md).
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: спека заведена, но это в ней не описано -->
- **Шаг конвейера идемпотентен по повтору.** Задача, брошенная на середине, - **Шаг конвейера идемпотентен по повтору.** Задача, брошенная на середине,
достаётся снова по истечении срока захвата и проходит шаг заново. достаётся снова по истечении срока захвата и проходит шаг заново.
- **Ядро зависит от интерфейсов.** `internal/service` знает только - **Ядро зависит от интерфейсов.** `internal/service` знает только
`internal/contract`; ffmpeg, Yandex, Telegram и SQLite подставляются в `internal/contract`; ffmpeg, Yandex, Telegram и хранилище подставляются в
`main.go`. `main.go`.
## Компоненты ## Компоненты
@@ -54,12 +56,15 @@
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности | | Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit | | Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit |
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам | | Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
| Репозитории | `internal/adapter/repo/sqlite` | Задачи и файлы, запросы через goqu | | Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом |
| Панель владельца | там же, `panel.go` | Правка задачи в панели проходит те же правила перехода, что и правка из кода |
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: спека заведена, но это в ней не описано --> <!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: спека заведена, но это в ней не описано -->
Конвейер: `created``converted``transcribe``done` либо `failed`. Три Конвейер: `created``converted``transcribe``done` либо `failed`. Три
воркера двигают по одному переходу, каждый опрашивает базу раз в секунду. воркера двигают по одному переходу, каждый опрашивает базу раз в секунду. Задача,
исчерпавшая попытки, уходит в `dead` мимо этой цепочки: её переводит туда не шаг,
а тот, кто её захватил.
## Внешние границы и форматы ## Внешние границы и форматы
@@ -91,7 +96,7 @@
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» | | Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции | | Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла» | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании | | ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла» | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
| SQLite (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — | | Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
| Диск | Запись файла падает, задача не заводится | — | — | — | | Диск | Запись файла падает, задача не заводится | — | — | — |
- **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота - **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота
@@ -107,7 +112,9 @@
| Что | Где | | Что | Где |
| --- | --- | | --- | --- |
| Приём аудио и заведение задачи | `TranscribeService.createTranscribeJob` — через него идут оба входа | | Приём аудио и заведение задачи | `TranscribeService.createTranscribeJob` — через него идут оба входа |
| Захват задачи воркером | `TranscriptJobRepository.FindAndAcquire` | | Правка задачи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает |
| Захват задачи воркером | `TranscriptJobRepository.FindAndAcquire` — один запрос с `RETURNING` |
| Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг |
| Переход задачи в состояние | `entity.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния | | Переход задачи в состояние | `entity.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния |
| Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю | | Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю |
| Разбор конфигурации | `internal/config.LoadConfig` | | Разбор конфигурации | `internal/config.LoadConfig` |
@@ -131,14 +138,9 @@
## Открытые вопросы ## Открытые вопросы
- **Хранилище.** PocketBase заменяет SQLite с goqu и goose, файлы переезжают в её
раскладку на диске — решено 2026-08-11,
[ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md), замер панели
в [research/pocketbase.md](research/pocketbase.md). Требование CGO этим
снимается. Чем становится конвейер задач, решено 2026-08-11 — см. «Очередь»
ниже. Данные не переносим — начинаем с чистого листа.
- **Учётные записи.** Вход через OIDC, провайдер — Authelia, а ответ провайдера - **Учётные записи.** Вход через OIDC, провайдер — Authelia, а ответ провайдера
обрабатывает PocketBase, а не наш код (тот же ADR). Не решено, где живёт сессия обрабатывает PocketBase, а не наш код
([ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)). Не решено, где живёт сессия
и как связываются пользователь Telegram и пользователь веба. Панель и как связываются пользователь Telegram и пользователь веба. Панель
администратора при этом Authelia не закрывает: у неё свой пароль администратора при этом Authelia не закрывает: у неё свой пароль
суперпользователя. суперпользователя.
@@ -168,25 +170,22 @@
копится — записи о потреблении или счётчики — решает задача копится — записи о потреблении или счётчики — решает задача
`usage-accounting`. `usage-accounting`.
- **Срок хранения.** Записи и тексты решено хранить бессрочно (паспорт, - **Срок хранения.** Записи и тексты решено хранить бессрочно (паспорт,
2026-08-11), а рост каталога `data/files` ничем не ограничен и не наблюдается. 2026-08-11), а рост каталога данных ничем не ограничен и не наблюдается.
- **Резервные копии.** Копии делает сервер своими средствами, и приложение о них - **Резервные копии.** Копии делает сервер своими средствами, и приложение о них
ничего не знает. После переезда на PocketBase не решено, хватит ли копировать ничего не знает. Не решено, хватит ли копировать каталог данных файлами, или
её каталог файлами, или приложению нужна команда выгрузки: база под нагрузкой приложению нужна команда выгрузки: база под нагрузкой копируется файлом не
копируется файлом не всегда целой. Своё копирование по расписанию у PocketBase всегда целой. Своё копирование по расписанию у PocketBase есть — берём мы его
есть — берём мы его или нет, тоже не решено. или нет, тоже не решено.
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а - **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
сервис определяет содержимое сам, то ли часть записей теряется на этом. сервис определяет содержимое сам, то ли часть записей теряется на этом.
- **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но - **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но
конвертер этот случай не проверялся. конвертер этот случай не проверялся.
- **Очередь.** Модель очереди решена 2026-08-11: остаётся своей таблицей и - **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12
становится коллекцией PocketBase, захват сворачивается в один запрос с ([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)) и нормирована
`RETURNING`, число попыток ложится колонкой, а исчерпавшая их задача переходит спекой `pipeline`. Не решено, отказываться ли от холостого опроса: три воркера
в состояние «мертва» вместо `is_error = 1` дают 259 200 запросов в сутки при нагрузке в единицы записей в день, и во что
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)). Пишет это это обходится, никто не мерил.
`pocketbase-storage` тем же заходом, что и хранилище. Не решено, отказываться
ли от холостого опроса: три воркера дают 259 200 запросов в сутки при нагрузке
в единицы записей в день, и во что это обходится, никто не мерил.
- **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать - **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать
счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой — счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой —
решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в
+15 -5
View File
@@ -15,6 +15,9 @@
- **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется - **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется
**приложением** в момент создания записи. **приложением** в момент создания записи.
*Расхождение:* идентификаторы записей выдаёт хранилище — 15 знаков
собственного алфавита. Своей точки генерации у приложения нет, и `ORDER BY id`
хронологией не является: порядок берут по колонке времени с ключом.
- Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология), - Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология),
компактен и удобен в URL и логах (без дефисов — grep и двойной клик берут id компактен и удобен в URL и логах (без дефисов — grep и двойной клик берут id
целиком), глобально уникален между таблицами — поиск по голому id находит все целиком), глобально уникален между таблицами — поиск по голому id находит все
@@ -43,14 +46,21 @@
- Enum-поля (`state`, `source`, …) — обычный `TEXT` без `CHECK`; допустимые - Enum-поля (`state`, `source`, …) — обычный `TEXT` без `CHECK`; допустимые
значения держит код. значения держит код.
*Расхождение:* перечень состояний задачи закрыт схемой (`SelectField`), а не
кодом — ради панели владельца: правка руками не должна заводить состояние,
которого конвейер не знает. Цена названа: шестое состояние потребует нового
шага схемы.
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например - Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет `2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`). лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
Единая точка генерации — приложение, а не умолчание в схеме: так забытая Единая точка генерации — приложение, а не умолчание в схеме: так забытая
вставка падает громко. Измерение длительности — не метка времени. вставка падает громко. Измерение длительности — не метка времени.
- Миграции — goose (`migrations/`): SQL-файлы для DDL; Go-миграции - Миграции — шаги PocketBase на Go (`internal/adapter/repo/pocketbase`):
(`goose.AddMigrationContext`) — когда нужен код (генерация id, заполнение коллекции и их поля заводятся кодом. При изменении структуры обновляем схему
задним числом). При изменении структуры обновляем схему
[../database.md](../database.md) тем же изменением. [../database.md](../database.md) тем же изменением.
- Добавляя колонку, соблюдай инвариант «Новая колонка правится во всех четырёх - Время в **сыром запросе** кладётся и сравнивается тем же видом, каким
местах» — [CLAUDE.md](../../CLAUDE.md), «Инварианты». хранилище пишет свои `created`/`updated`. Сравнение строк побайтово, и
разошедшийся вид обращает условие в постоянную истину или ложь — молча.
- Выборка «следующей» записи с `LIMIT 1` дополняется ключом в `ORDER BY`:
сравнение по неуникальному значению делает порядок обработки
невоспроизводимым.
+7 -2
View File
@@ -202,8 +202,13 @@ Object Storage, скачивание файла из Telegram и опрос оп
периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом
`INFO` они не пишутся. `INFO` они не пишутся.
*Расхождение:* `sloggin` пишет все запросы одинаково, `/health` и `/metrics` Расхождения здесь больше нет: слой журналирования запросов свой,
попадают в лог наравне с остальными. `main.go`, хук `OnServe` — вместе с gin ушёл и `sloggin`. `/health` и `/metrics`
идут на `DEBUG`, то есть при боевом `INFO` не пишутся вовсе.
Хранилище ведёт **свой** журнал запросов в собственной таблице, и он виден
владельцу в панели. Заменой потоку процесса он не служит: в журнал контейнера,
по которому разбирают отказы, эта таблица не попадает.
## Безопасность: что не логируем ## Безопасность: что не логируем
+107 -59
View File
@@ -1,101 +1,149 @@
# Схема хранилища # Схема хранилища
СУБД, миграции, правило времени и идентификаторов. Хранилище, коллекции, правило времени и идентификаторов.
СУБД — SQLite, драйвер `mattn/go-sqlite3` (нужен CGO). Запросы строит Хранилище — **встроенная PocketBase 0.39.10**: она держит и базу, и файлы
`doug-martin/goqu` с диалектом `sqlite3`. Миграции — `pressly/goose`, каталог записей под одним каталогом данных. Ключ конфигурации — `[storage] data_dir`,
`migrations/`, вшит в бинарник через `//go:embed migrations/*.sql` в `main.go` и умолчание `data`. В SQLite библиотека ходит через `modernc.org/sqlite`, поэтому
накатывается при старте. Новый файл достаточно положить в каталог. CGO сборке не нужен.
**Идентификаторы** — UUID v4 строкой. Схему двигают **шаги миграций PocketBase** на Go, каталог
`internal/adapter/repo/pocketbase`, файл шага — `migrations.go`. Шаг
регистрируется при загрузке пакета, а накатывается при подъёме хранилища
(`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый шаг не
переписывается — изменение только новым шагом: применённое хранилище считает по
имени файла.
**Время** — локальная зона процесса, UTC не навязан. Колонки `created_at` и **Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита.
`updated_at` проставляет приложение, а не СУБД; умолчание `CURRENT_TIMESTAMP` Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в
стоит только у `files.created_at`. хранилище, задаёт сервис, и это `<uuid><расширение>`.
**Время** — вид хранилища: строка `2006-01-02 15:04:05.000Z` в UTC. Колонки
`created` и `updated` проставляет само хранилище; те же поля в сыром запросе
захвата кладёт наш код — **тем же видом**, потому что сравнение строк в SQLite
побайтово, и разошедшийся вид обратил бы условие срока в постоянную истину или
постоянную ложь молча.
Того, что единой точки генерации идентификатора и времени нет, здесь не Того, что единой точки генерации идентификатора и времени нет, здесь не
повторяем: перечень единых точек и их отсутствий держит повторяем: перечень единых точек и их отсутствий держит
[architecture.md](architecture.md), «Единые точки проекта». [architecture.md](architecture.md), «Единые точки проекта».
Переход на PocketBase запланирован, но не начат — здесь описано сегодняшнее ## Коллекции
состояние. Открытые вопросы перехода — в
[architecture.md](architecture.md), раздел «Открытые вопросы».
## Таблицы
### `files` ### `files`
Один файл на одну физическую копию: исходник, результат конвертации и копия в Один файл на одну физическую копию: исходник, результат конвертации и копия в
Object Storage — три разные записи. Object Storage — три разные записи.
| Колонка | Тип | Что | | Поле | Тип | Что |
| --- | --- | --- | | --- | --- | --- |
| `id` | TEXT PK | UUID файла | | `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
| `storage` | TEXT | `local` или `s3` | | `file` | file | Сам файл; пусто у копии в Object Storage |
| `file_name` | TEXT | Имя в хранилище: UUID с расширением | | `location` | select | `local` или `s3` |
| `object_key` | TEXT | Ключ объекта; пусто у местной копии |
| `size` | INTEGER | Размер в байтах | | `size` | INTEGER | Размер в байтах |
| `created_at` | DATETIME | Умолчание `CURRENT_TIMESTAMP` | | `created`, `updated` | DATETIME | Проставляет хранилище |
Поле названо `location`, а не `storage`: последним словом зовут само хранилище и
capability, и третий смысл развёл бы одно слово по разным вещам.
### `transcribe_jobs` ### `transcribe_jobs`
Задача расшифровки и она же очередь. Задача расшифровки и она же очередь.
| Колонка | Тип | Что | | Поле | Тип | Что |
| --- | --- | --- | | --- | --- | --- |
| `id` | TEXT PK | UUID задачи | | `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
| `state` | TEXT | `created`, `converted`, `transcribe`, `done`, `failed` | | `state` | select | `created`, `converted`, `transcribe`, `done`, `failed`, `dead`; перечень закрыт схемой |
| `source` | TEXT | `api`, `telegram`, `unknown`; умолчание `unknown` | | `source` | select | `api`, `telegram`, `unknown` |
| `file_id` | TEXT FK`files.id` | **Текущий** файл задачи: шаг конвейера переставляет ссылку на свой результат | | `file` | relation`files` | **Текущий** файл задачи: шаг конвейера переставляет ссылку на свой результат |
| `delay_time` | DATETIME | Не брать задачу раньше этого времени | | `delay_time` | DATETIME | Не брать задачу раньше этого времени |
| `acquisition_id` | TEXT | Кто захватил задачу | | `acquisition_id` | TEXT | Кто захватил задачу |
| `acquire_time` | DATETIME | Когда захватил; по нему считается протухание | | `acquire_time` | DATETIME | Когда захватил; по нему считается протухание |
| `attempts` | INTEGER ≥ 0 | Число попыток: растёт при захвате, обнуляется на шаге без отказа |
| `recognition_op_id` | TEXT | Идентификатор операции в Yandex Cloud | | `recognition_op_id` | TEXT | Идентификатор операции в Yandex Cloud |
| `transcription_text` | TEXT | Результат распознавания | | `transcription_text` | editor | Результат распознавания |
| `is_error` | BOOLEAN | Задача с `1` из выборки исключена навсегда |
| `error_text` | TEXT | Текст ошибки, машинный | | `error_text` | TEXT | Текст ошибки, машинный |
| `tg_chat_id` | INTEGER | Куда отправить результат | | `tg_chat_id` | INTEGER | Куда отправить результат |
| `tg_reply_message_id` | INTEGER | С каким сообщением связать | | `tg_reply_message_id` | INTEGER | С каким сообщением связать |
| `created_at`, `updated_at` | DATETIME | Проставляет приложение | | `created`, `updated` | DATETIME | Проставляет хранилище |
Индексов, кроме первичных ключей, нет. Выборка воркера идёт полным перебором по Индекс один — по `state`: выборка воркера идёт по нему, паузе и сроку захвата.
`state`, `is_error`, `delay_time` и `acquire_time`. Прежней колонки `is_error` нет: задача выбывает из выборки состоянием, и способ
этот один.
**Состояния `failed` и `dead` — разные приговоры.** В `failed` задачу переводит
шаг, рассудивший об этой записи окончательно; в `dead` она уходит без такого
суждения — мы повторяли и перестали. Ни один шаг конвейера в `dead` не переводит
сам: это делает тот, кто захватил задачу с превышенным счётчиком.
**Правила доступа обеих коллекций пусты**, то есть перечислять и читать записи
может только владелец панели. Проверено прогоном: анонимный запрос к
`/api/collections/*/records` отвечает `403`, к `/api/logs`, `/api/backups`,
`/api/settings` и `/api/crons``401`.
## Представление данных ## Представление данных
Чем физически лежит запись и что происходит при чтении и записи. Чем физически лежит запись и что происходит при чтении и записи.
- **Расшифровка лежит целиком в колонке `transcription_text`** одной строкой. - **Расшифровка лежит целиком в поле `transcription_text`** одной строкой.
Запись длиной в час даёт десятки килобайт в одной ячейке; читается она Запись длиной в час даёт десятки килобайт в одной ячейке; читается она
целиком при каждом `GetByID` и при каждом захвате задачи воркером. целиком при каждом чтении задачи и при каждом захвате.
- **Аудио в базе не лежит.** На диске — каталог `data/files`, плоский, имя файла - **Аудио лежит в раскладке хранилища:**
равно UUID с расширением. Ни файлы, ни объекты в Object Storage не удаляются `data/storage/<коллекция>/<запись>/<имя>` рядом с файлом атрибутов. Имя задаёт
после завершения задачи: каталог и бакет растут неограниченно. сервис — `<uuid><расширение>`; собственного суффикса хранилище не дописывает,
- **Захват задачи — два запроса подряд, не транзакция.** Сперва `UPDATE … потому что умолчание, строящее имя из имени отправителя, не применяется. Ни
WHERE id = (SELECT … LIMIT 1)` проставляет `acquisition_id`, затем отдельный файлы, ни объекты в Object Storage не удаляются после завершения задачи:
`SELECT … WHERE acquisition_id = ?` читает строку. Репозиторий сверяет число каталог и бакет растут неограниченно.
затронутых строк с ожидаемым, но между запросами задачу может перехватить - **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
другой воркер с тем же значением — на одном процессе это не наблюдалось. не помечено защищённым: право прочитать запись даёт знание её идентификатора,
- **Список колонок задан не одним местом** — четырьмя запросами файла и файл встаёт вровень с опросом готовности задачи. Поэтому имя файла в
`internal/adapter/repo/sqlite/transcript_job_repo.go`. Правило правки всех хранилище **в журнал не пишется** — оно последняя часть ссылки.
четырёх и его severity — инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты». - **Захват задачи — один запрос с `RETURNING`**, мимо записей коллекции.
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
заведения **и по ключу**: время неуникально, и без ключа порядок обработки
невоспроизводим.
- **Запись результата условна по признаку захвата.** Шаг, чей захват за время
работы достался другому, завершается без записи и без ответа отправителю.
- **Список колонок задан четырьмя местами** — `applyToRecord`, `recordToJob`,
константой `acquireColumns` и структурой `acquiredRow`, — плюс шагом схемы.
Все четыре лежат в одном пакете, но компилятор видит два: правило правки и его
серьёзность (critical/major) — инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты».
- **Отказ хранилища наружу не выходит дословно.** Он несёт ключ файла целиком, а
ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают
свой текст с идентификатором записи, а цепочку `%w` обрывают. То же у выгрузки
в Object Storage: отказ SDK несёт полный URL объекта.
## Настройки с числовым значением ## Настройки с числовым значением
| Настройка | Значение | Где | | Настройка | Значение | Где | Откуда число |
| --- | --- | --- | | --- | --- | --- | --- |
| Срок захвата, конвертация и распознавание | 1 час | `service/transcribe.go`, вызовы `findJob` | | Предел попыток | 5 | `service/transcribe.go` | обычное умолчание, не замер |
| Срок захвата, проверка операции | 24 часа | там же | | Пауза перед повтором | `2^(попытка−1)` с, потолок 5 минут | там же | то же |
| Пауза воркера между попытками | 1 секунда | `controller/worker/worker.go` | | Срок захвата, конвертация | 8 часов | там же | потолок записи 6 часов плюс запас |
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | | Срок захвата, распознавание | 8 часов | там же | то же |
| Задержка между проверками операции | 5 секунд | там же | | Срок захвата, проверка операции | 1 час | там же | опрос идёт секунды |
| Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` | | Задержка перед первой проверкой операции | 10 секунд | там же | как было |
| Память под multipart-загрузку | 32 МиБ | `main.go`, `router.MaxMultipartMemory` | | Задержка между проверками операции | 5 секунд | там же | как было |
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | | Пауза воркера между попытками | 1 секунда | `controller/worker/worker.go` | как было |
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | | Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` | предел Telegram |
| Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` | | Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | | Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
| Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` | — |
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
Чего среди настроек **нет**: режим журналирования SQLite не задан (значение по **Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
умолчанию, не WAL), таймаут занятости не задан, размер пула соединений не задан, тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
срока хранения файлов и объектов нет вовсе. Таймаутов у обращений к Telegram, S3 и библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело
SpeechKit тоже нет — ни одного. на 32 МиБ раньше обработчика. Оставленные умолчания отвергали бы всё длиннее
примерно пяти минут. Таймаут чтения запроса снят: шесть часов записи по
медленному каналу переживают любой фиксированный, а стойкость к целенаправленной
нагрузке объявлена вне модели угроз.
Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер
пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения
файлов и объектов нет вовсе. Таймаутов у
обращений к Telegram, S3 и SpeechKit тоже нет — ни одного.
+1
View File
@@ -22,6 +22,7 @@ SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, чт
| Дата | Запись | О чём | | Дата | Запись | О чём |
| --- | --- | --- | | --- | --- | --- |
| 2026-08-12 | [PocketBase: умолчания, которые ломают штатный сценарий](pocketbase-defaults.md) | Потолок файла 5 МиБ, тело 32 МиБ, таймаут чтения, суффикс имени, хук правки |
| 2026-08-11 | [gRPC-клиент SpeechKit: когда закрытие вообще может отказать](grpc-client-close.md) | Ленивое соединение и два исхода `Close` в grpc v1.74.2 | | 2026-08-11 | [gRPC-клиент SpeechKit: когда закрытие вообще может отказать](grpc-client-close.md) | Ленивое соединение и два исхода `Close` в grpc v1.74.2 |
| 2026-08-11 | [Фреймворк приложения: Svelte, Vue и React на одном экране](spa-framework.md) | Размер собранной статики, цена шага сборки, что у трёх кандидатов одинаково | | 2026-08-11 | [Фреймворк приложения: Svelte, Vue и React на одном экране](spa-framework.md) | Размер собранной статики, цена шага сборки, что у трёх кандидатов одинаково |
| 2026-08-11 | [Очередь задач: своя таблица против готовой библиотеки](job-queue.md) | Цена River и goqite в пакетах, захват одним запросом, чего нет для PocketBase | | 2026-08-11 | [Очередь задач: своя таблица против готовой библиотеки](job-queue.md) | Цена River и goqite в пакетах, захват одним запросом, чего нет для PocketBase |
+98
View File
@@ -0,0 +1,98 @@
# PocketBase: умолчания, которые ломают штатный сценарий
Наблюдения, снятые по ходу задачи `pocketbase-storage` уже на своём коде. От
[записки разведки](pocketbase.md) отличаются предметом: та мерила, **что даёт
панель**, эта — **что библиотека делает молча**, если её не переубедить.
Все четыре наблюдения нашлись ревью, а не чтением документации: три из них
выглядят как «значение по умолчанию — нет ограничения», а значат обратное.
## Как снималось
Версия **0.39.10**, та же, что у первой записки. Прогоны — на пустом каталоге
данных во временном каталоге и на поднятом сервере `127.0.0.1:18099`; боевые
данные и ключи не участвовали. Числа ниже сняты 2026-08-11 и 2026-08-12.
## Нулевой потолок у поля файла значит 5 МиБ, а не «без предела»
`&core.FileField{MaxSize: 0}` читается библиотекой как её собственное умолчание:
```
core/field_file.go:28 const DefaultFileFieldMaxSize int64 = 5 << 20
core/field_file.go:310 if f.MaxSize <= 0 { return DefaultFileFieldMaxSize }
```
Проверено укладкой: файл в 6 МиБ отвергается на сохранении записи —
`the maximum allowed file size is 5242880 bytes`. Прогон через боевой роутер дал
границу дословно:
| тело запроса | ответ |
| --- | --- |
| 4 194 304 байта | `201` |
| 5 238 784 байта | `201` |
| 5 246 976 байт | `500` |
| 34 603 008 байт | `413` |
**Цена для сервиса:** 5 МиБ — это примерно 5,5 минут mp3 при 128 кбит/с. Отвергалась
бы не только длинная запись на приёме: результат конвертации в ogg переваливает
тот же порог примерно на пятой минуте, и **уже принятая** задача исчерпывала бы
попытки на шаге конвертации.
## Тело запроса режется на 32 МиБ раньше обработчика
`apis/base.go:36` вешает `BodyLimit(DefaultMaxBodySize)` на **корневой** роутер,
то есть и на чужие маршруты; `apis/middlewares_body_limit.go:14`
`const DefaultMaxBodySize int64 = 32 << 20`. Ответ `413` уходит мимо обработчика,
без строки в журнале приёма (последняя строка таблицы выше).
Снимается на маршруте: `.Bind(apis.BodyLimit(<своё число>))`.
## Таймаут чтения запроса — пять минут
`apis/serve.go:151` ставит `ReadTimeout: 5 * time.Minute`. Заливка шестичасовой
записи по медленному каналу его переживает: соединение рвётся на середине.
Снимается в хуке `OnServe``se.Server.ReadTimeout = 0`.
## Суффикс к имени файла дописывает конструктор, а не укладка
Первая записка наблюдала `sample.ogg → sample_uztrv6wvz3.ogg` и читала это как
свойство хранилища. Наблюдение верно **только когда имя строит сама библиотека**:
десять случайных знаков добавляет `normalizeName`, вызываемый из
`filesystem.NewFileFrom*`. Имя, положенное в поле `File.Name` после
конструктора, ложится на диск дословно:
```
задано 11111111-2222-3333-4444-555555555555.mp3
на диске 11111111-2222-3333-4444-555555555555.mp3
```
**Цена:** тот, кто задаёт имя сам, не получает от суффикса никакой
неугадываемости — и защищать ссылку на файл ему приходится другим.
## Хук правки записи не различает, кто пишет
`app.OnRecordUpdate(<коллекция>)` — событие **модели**: оно срабатывает на каждом
`app.Save`, включая сохранение из собственного кода. Хук, написанный «для
панели», правил записи конвейера: проверено прогоном — задержка, поставленная
шагом вместе со сменой состояния, обнулялась тем же сохранением.
Различает источник `app.OnRecordUpdateRequest(<коллекция>)`: оно поднимается
только на правку запросом, а код, пишущий мимо HTTP-слоя, под него не попадает.
## Приглашение завести владельца панели живёт полчаса
`apis/installer.go:31``systemSuperuser.NewStaticAuthToken(30 * time.Minute)`;
печатается только пока владельца нет (`needInstallerSuperuser`). Проверено
прогоном: при первом запуске строка со ссылкой в журнале есть, после заведения
владельца при следующем запуске её нет.
## Чего эта записка не узнала
- **Во что обходится потолок в 8 ГиБ на диске.** Число выбрано расчётом из
шестичасовой записи с запасом на видео, а не замером: настоящего распределения
длин у сервиса нет.
- **Как ведёт себя укладка файла в несколько гигабайт.** Самая длинная проверенная
запись — 9,6 МБ (десять минут mp3). Потоковую укладку это подтверждает, предел
— нет.
- **Поведение под одновременной правкой панели и конвейера в бою.** Проверено
тестом на одной машине, не живой нагрузкой.
+4
View File
@@ -69,6 +69,10 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
Проверено загрузкой файла в 200 КБ: имя `sample.ogg` превратилось в Проверено загрузкой файла в 200 КБ: имя `sample.ogg` превратилось в
`sample_uztrv6wvz3.ogg`, рядом лёг файл атрибутов. `sample_uztrv6wvz3.ogg`, рядом лёг файл атрибутов.
*Уточнено 2026-08-12:* суффикс дописывает конструктор имени, а не укладка. Имя,
заданное после конструктора, ложится на диск дословно — см.
[pocketbase-defaults.md](pocketbase-defaults.md).
Сегодняшняя раскладка `data/files` с именами-UUID панели не видна. Путь она Сегодняшняя раскладка `data/files` с именами-UUID панели не видна. Путь она
покажет строкой — прослушать и скачать запись по ней нельзя. Способа сослаться покажет строкой — прослушать и скачать запись по ней нельзя. Способа сослаться
на файл, уже лежащий на диске мимо её каталога, нет. на файл, уже лежащий на диске мимо её каталога, нет.
+22
View File
@@ -197,6 +197,28 @@ API и имя не откатываются обратной правкой по
поймать их было некому. У восстановленных нет поля «Чем воспроизведён», и поймать их было некому. У восстановленных нет поля «Чем воспроизведён», и
выдумывать его задним числом нельзя. выдумывать его задним числом нельзя.
## 2026-08-12 — образ не собирался, и этого не увидел никто [проскочил]
**Что сломалось.** `go mod tidy` поднял директиву `go` в `go.mod` до `1.25.0`
её требует PocketBase, — а `Dockerfile` продолжал собирать на `golang:1.24-alpine`
с `GOTOOLCHAIN=local`. `task image` упал бы на шаге сборки: выкладки задачи
`pocketbase-storage` не существовало бы вовсе.
**Почему не поймали.** Все шесть проходов ревью и весь гейт видели зелёное:
`go build ./...` идёт на хостовом Go, а образ не собирает **ни один шаг гейта**.
Расхождение выглядело согласованным ещё и потому, что `CLAUDE.md` и `README.md`
обещали Go 1.24 — то есть три места из четырёх говорили одно и то же, и неверными
были именно они.
Нашлось не проходом, а триажем — при проверке чужих починок на месте, когда он
собрал образ руками. То есть поймано случайным свойством прогона, а не
устройством конвейера: проверь триаж починки чтением, дефект уехал бы в мердж.
**Чем чинится на будущее.** Сборка образа гейтом не проверяется намеренно —
дорого. Дешёвая замена: шаг, сверяющий версию сборщика в `Dockerfile` с
директивой `go` в `go.mod`. Строкой сравнения, без docker. Заведено урожаем
ревью.
## 2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью] ## 2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью]
- **Где:** дельта-спека `pipeline` задачи `errors-as-instead-of-typecast`, абзац - **Где:** дельта-спека `pipeline` задачи `errors-as-instead-of-typecast`, абзац
+33 -26
View File
@@ -85,16 +85,27 @@ Telegram отправителю.
Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда
путь не предполагался. путь не предполагался.
- **Путь на диске** `filepath.Join(cfg.Storage.Path, fileId + ext)`, где - **Путь на диске** выбирает хранилище:
`fileId` наш UUID, а **`ext` берётся из имени файла отправителя** через `data/storage/<коллекция>/<запись>/<имя>`. **Имя задаёт сервис**
`filepath.Ext`. Расширение в путь попадает без проверки списком; `filepath.Ext` `<uuid><расширение>`, — а умолчание PocketBase, строящее имя из имени
режет по последней точке и не пропускает разделитель каталогов, но это отправителя, не применяется: имя отправителя в хранилище не попадает.
единственное, что стоит между входом и именем файла. Расширение берётся из имени отправителя через `filepath.Ext` без проверки
списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
каталогов, но это единственное, что стоит между входом и именем файла.
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с - **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
расширением. Бакет один на все записи, префикса по пользователю нет. расширением. Бакет один на все записи, префикса по пользователю нет.
- **Каталог** один и плоский: `data/files` целиком, вложенности нет. - **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла не
- **Идентификатор задачи** — UUID v4. Он же единственное, что защищает помечено защищённым, поэтому ссылка сама по себе и есть право пройти по ней, а
`GET /api/status/:id`. отзыва у неё нет. Отсюда запрет: **имя файла в хранилище в журнал не пишется**
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку
целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
- **Идентификатор задачи** — 15 знаков, выдаёт хранилище. Он же единственное,
что защищает `GET /api/status/:id`.
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят
`/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`,
`/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то
есть доступны они только владельцу панели; проверено прогоном — записи отдают
`403`, служебные разделы `401`.
Целевой периметр добавляет сюда три вещи, и все три — от новых задач: Целевой периметр добавляет сюда три вещи, и все три — от новых задач:
@@ -103,15 +114,7 @@ Telegram отправителю.
поиск отдавал бы чужую расшифровку тому, кто угадал или добыл тот же файл, и поиск отдавал бы чужую расшифровку тому, кто угадал или добыл тот же файл, и
заодно сообщал бы, что запись у кого-то уже есть. заодно сообщал бы, что запись у кого-то уже есть.
- **Файлы фрагментов** (`long-audio-chunking`) ложатся рядом с исходным в тот же - **Файлы фрагментов** (`long-audio-chunking`) ложатся рядом с исходным в тот же
плоский каталог — раскладка `data/files` меняется, и это необратимо. плоский каталог — раскладка каталога данных меняется, и это необратимо.
- **Раскладку выбирает PocketBase** (`pocketbase-storage`), и плоского каталога
не остаётся вовсе: файл ложится в
`pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>` рядом с
файлом атрибутов. Имя, данное отправителем, в путь при этом попадает — сегодня
от него берётся только расширение. Файл уходит не с диска напрямую, а по ссылке
вида `/api/files/<коллекция>/<запись>/<имя>`; закрытым он становится, только если
поле помечено защищённым, и тогда нужен отдельный файловый токен. Замер —
[research/pocketbase.md](research/pocketbase.md).
- **Имя отправляемого документа** (`long-text-delivery`) собирается из - **Имя отправляемого документа** (`long-text-delivery`) собирается из
идентификатора задачи: имя, данное пользователем, в него не попадает. идентификатора задачи: имя, данное пользователем, в него не попадает.
@@ -180,10 +183,14 @@ Telegram отправителю.
5. **Статистика потребления** (`usage-accounting`). Текста записей не содержит, 5. **Статистика потребления** (`usage-accounting`). Текста записей не содержит,
но говорит, кто и когда пользовался сервисом и сколько; страница расхода но говорит, кто и когда пользовался сервисом и сколько; страница расхода
открыта только владельцу. открыта только владельцу.
6. **Пароль суперпользователя панели** (`pocketbase-storage`). Открывает все 6. **Пароль владельца от панели.** Открывает все записи, все файлы и всех
записи, все файлы и всех пользователей разом, то есть стоит вровень с самым пользователей разом, то есть стоит вровень с самым чувствительным из списка
чувствительным из списка выше. Второй секрет после токенов пользователей, выше. Второй секрет после токенов пользователей, который лежит **не в
который лежит **не в конфигурации**: его отпечаток хранит сама база. конфигурации**: его отпечаток хранит сама база, а задаёт пароль сам владелец
по приглашению, которое сервис печатает в журнал при первом запуске. У
приглашения тридцать минут жизни, и после того как владелец заведён, оно не
печатается вовсе — иначе строка журнала отдавала бы панель всякому его
читателю навсегда.
Тексты расшифровок в логи не пишутся — логируется длина текста и Тексты расшифровок в логи не пишутся — логируется длина текста и
идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано
@@ -194,10 +201,10 @@ Telegram отправителю.
инварианта приватности из [../CLAUDE.md](../CLAUDE.md), а не незакрытый остаток. инварианта приватности из [../CLAUDE.md](../CLAUDE.md), а не незакрытый остаток.
Расширение берётся из имени отправителя дословно (`filepath.Ext`), поэтому имя Расширение берётся из имени отправителя дословно (`filepath.Ext`), поэтому имя
`запись.тайное-слово` отдаёт `тайное-слово`, а `Разговор с Петровым 11.08` `запись.тайное-слово` отдаёт `тайное-слово`, а `Разговор с Петровым 11.08`
`08`. Оно стоит в собственном имени файла на диске, а путь к файлу логируется. `08`. В журнал оно идёт собственным полем, а не в составе имени файла: по нему
Читает этот журнал владелец сервиса. Нормализация расширения на диске — отдельная прослеживается путь записи. Читает этот журнал владелец сервиса. Нормализация
работа, задачи на неё пока нет: формат имени файла объявлен необратимым и меняется расширения в хранилище — отдельная работа, задачи на неё пока нет: формат имени
решением человека. файла объявлен необратимым и меняется решением человека.
**Наружу хвост не выходит.** Метки метрик (`file_extension` у **Наружу хвост не выходит.** Метки метрик (`file_extension` у
`transcriber_input_file_size_bytes`, `source_format` у `transcriber_input_file_size_bytes`, `source_format` у
@@ -236,7 +243,7 @@ Telegram отправителю.
замер: `research/` пуст, потолок длины стоит открытым вопросом замер: `research/` пуст, потолок длины стоит открытым вопросом
`architecture.md`, «Долгие записи», — а квот нет и не будет: решено считать расход и показывать его владельцу, а не отказывать `architecture.md`, «Долгие записи», — а квот нет и не будет: решено считать расход и показывать его владельцу, а не отказывать
(цель `usage-stats`). Перебравшего останавливает разговор или отзыв доступа в (цель `usage-stats`). Перебравшего останавливает разговор или отзыв доступа в
Authelia. Рост каталога `data/files` при этом ничем не наблюдается — Authelia. Рост каталога данных при этом ничем не наблюдается —
открытый вопрос `architecture.md`. открытый вопрос `architecture.md`.
- **Перерасход денег на внешних сервисах.** Распознавание и языковая модель - **Перерасход денег на внешних сервисах.** Распознавание и языковая модель
оплачиваются по факту; потолка на пользователя нет по тому же решению. оплачиваются по факту; потолка на пользователя нет по тому же решению.
+35 -37
View File
@@ -1,6 +1,6 @@
module git.vakhrushev.me/av/transcriber module git.vakhrushev.me/av/transcriber
go 1.24.5 go 1.25.0
require ( require (
github.com/BurntSushi/toml v1.5.0 github.com/BurntSushi/toml v1.5.0
@@ -9,21 +9,19 @@ require (
github.com/aws/aws-sdk-go-v2/credentials v1.18.3 github.com/aws/aws-sdk-go-v2/credentials v1.18.3
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3 github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.86.0 github.com/aws/aws-sdk-go-v2/service/s3 v1.86.0
github.com/doug-martin/goqu/v9 v9.19.0
github.com/gin-gonic/gin v1.10.1
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.31 github.com/pocketbase/dbx v1.12.0
github.com/pressly/goose/v3 v3.24.3 github.com/pocketbase/pocketbase v0.39.10
github.com/prometheus/client_golang v1.23.0 github.com/prometheus/client_golang v1.23.0
github.com/samber/slog-gin v1.15.1
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
github.com/yandex-cloud/go-genproto v0.17.0 github.com/yandex-cloud/go-genproto v0.17.0
google.golang.org/grpc v1.74.2 google.golang.org/grpc v1.74.2
) )
require ( require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect
@@ -37,47 +35,47 @@ require (
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.27.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 // indirect
github.com/aws/smithy-go v1.22.5 // indirect github.com/aws/smithy-go v1.27.7 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.11.9 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.4 // indirect github.com/disintegration/imaging v1.6.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/fatih/color v1.19.0 // indirect
github.com/go-playground/validator/v10 v10.22.0 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/goccy/go-json v0.10.3 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/ganigeorgiev/fexpr v0.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/go-sql-driver/mysql v1.9.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect github.com/mattn/go-colorable v0.1.15 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/mattn/go-isatty v0.0.23 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/pocketbase/ozzo-validation/v4 v4.3.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.65.0 // indirect github.com/prometheus/common v0.65.0 // indirect
github.com/prometheus/procfs v0.16.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/spf13/cast v1.10.0 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect github.com/spf13/cobra v1.10.2 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect github.com/spf13/pflag v1.0.10 // indirect
go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.54.0 // indirect
golang.org/x/arch v0.8.0 // indirect golang.org/x/image v0.44.0 // indirect
golang.org/x/crypto v0.38.0 // indirect golang.org/x/net v0.57.0 // indirect
golang.org/x/net v0.40.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.14.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.33.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.25.0 // indirect golang.org/x/text v0.40.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect
google.golang.org/protobuf v1.36.7 // indirect google.golang.org/protobuf v1.36.7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.55.0 // indirect
) )
+106 -111
View File
@@ -1,7 +1,10 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go-v2 v1.37.2 h1:xkW1iMYawzcmYFYEV0UCMxc8gSsjCGEhBXQkdQywVbo= github.com/aws/aws-sdk-go-v2 v1.37.2 h1:xkW1iMYawzcmYFYEV0UCMxc8gSsjCGEhBXQkdQywVbo=
github.com/aws/aws-sdk-go-v2 v1.37.2/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg= github.com/aws/aws-sdk-go-v2 v1.37.2/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg=
@@ -40,99 +43,82 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 h1:bRP/a9llXSSgDPk7Rqn5GD/DQCGo
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0/go.mod h1:tgBsFzxwl65BWkuJ/x2EUs59bD4SfYKgikvFDJi1S58= github.com/aws/aws-sdk-go-v2/service/sts v1.36.0/go.mod h1:tgBsFzxwl65BWkuJ/x2EUs59bD4SfYKgikvFDJi1S58=
github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw= github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw=
github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/aws/smithy-go v1.27.7 h1:Zgj5z4LfcDYoQIVk+n/yGdTkP/2y6ZT5vYxe0fp7bqE=
github.com/aws/smithy-go v1.27.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/sonic v1.11.9 h1:LFHENlIY/SLzDWverzdOvgMztTxcfcF+cqNsz9pK5zg=
github.com/bytedance/sonic v1.11.9/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/doug-martin/goqu/v9 v9.19.0 h1:PD7t1X3tRcUiSdc5TEyOFKujZA5gs3VSA7wxSvBx7qo= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/doug-martin/goqu/v9 v9.19.0/go.mod h1:nf0Wc2/hV3gYK9LiyqIrzBEVGlI8qW3GuDCEobC4wBQ= github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus=
github.com/ganigeorgiev/fexpr v0.6.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw=
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/lib/pq v1.10.1 h1:6VXZrLU0jHBYyAqrSPa+MgPfnSvTPuMgK+k0o5kVFWo= github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.31 h1:ldt6ghyPJsokUIlksH63gWZkG6qVGeEAu4zLeS4aVZM=
github.com/mattn/go-sqlite3 v1.14.31/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM= github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E= github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/ozzo-validation/v4 v4.3.0 h1:uKBDVma7bZqgR2a6AwE+k9hkuDFfiZMpBHQdZ1z3iQs=
github.com/pocketbase/ozzo-validation/v4 v4.3.0/go.mod h1:6XNjSTw/Jb2F8LOkKO3oyzIWExbrGiYoS4uVxVwz90g=
github.com/pocketbase/pocketbase v0.39.10 h1:2j8TDJRuo3aAC8Y8F9WFux0SwYcxeDCgEYQxxdWkwGE=
github.com/pocketbase/pocketbase v0.39.10/go.mod h1:tSX3anHQ7Ul6dPV9WhlEc6No1DtklGF69iwnVNW3BEE=
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
@@ -145,28 +131,18 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/samber/slog-gin v1.15.1 h1:jsnfr+S5HQPlz9pFPA3tOmKW7wN/znyZiE6hncucrTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/slog-gin v1.15.1/go.mod h1:mPAEinK/g2jPLauuWO11m3Q0Ca7aG4k9XjXjXY8IhMQ= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yandex-cloud/go-genproto v0.17.0 h1:uQ5Lr8B/xIyY1KrOm7pItYY3YT/DL1O8gVaY03ouYKM= github.com/yandex-cloud/go-genproto v0.17.0 h1:uQ5Lr8B/xIyY1KrOm7pItYY3YT/DL1O8gVaY03ouYKM=
github.com/yandex-cloud/go-genproto v0.17.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo= github.com/yandex-cloud/go-genproto v0.17.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
@@ -183,32 +159,33 @@ go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKr
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ= google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw= google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE= google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
@@ -220,16 +197,34 @@ google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y= modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs= modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+12 -1
View File
@@ -2,6 +2,7 @@ package yandex
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"strings" "strings"
@@ -11,6 +12,7 @@ import (
"github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/feature/s3/manager" "github.com/aws/aws-sdk-go-v2/feature/s3/manager"
"github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/smithy-go"
) )
type s3Config struct { type s3Config struct {
@@ -72,7 +74,16 @@ func (s *yandexS3Service) uploadFile(file io.Reader, fileName string) error {
Body: file, Body: file,
}) })
if err != nil { if err != nil {
return fmt.Errorf("failed to upload file to S3: %w", err) // Отказ SDK несёт полный URL объекта, то есть имя файла в хранилище, а
// оно — последняя часть ссылки на скачивание: цепочка `%w` уехала бы в
// журнал вместе с ключом. Наружу идёт класс отказа и только он — по
// нему «ключи отозваны» отличимо от «бакета нет» и от «сети нет», а
// адреса в коде отказа SDK не бывает.
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
return fmt.Errorf("failed to upload file to S3: %s", apiErr.ErrorCode())
}
return errors.New("failed to upload file to S3")
} }
return nil return nil
+56
View File
@@ -0,0 +1,56 @@
// Package pocketbase — хранилище задач и файлов поверх встроенной PocketBase.
//
// Приложение поднимается библиотекой, а не её набором команд: разбор флагов и
// мягкая остановка остаются нашими, а ключ `-c config.toml` — объявленный
// контракт запуска.
package pocketbase
import (
"fmt"
pb "github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
)
// Имена коллекций. Они же — часть пути к файлу в раскладке хранилища и часть
// адреса ссылки на него, поэтому меняются только новым шагом схемы.
const (
FilesCollection = "files"
JobsCollection = "transcribe_jobs"
)
// New создаёт приложение хранилища на заданном каталоге данных и приводит его в
// рабочее состояние: открывает базу, читает настройки и накатывает непринятые
// шаги схемы.
//
// Схема накатывается **здесь**, а не оставляется серверу, хотя тот и гоняет
// непринятые шаги сам. Причина в порядке: воркеры стартуют раньше сервера, и на
// чистом каталоге их первые опросы приходились бы на несуществующую таблицу —
// отказ в журнале и в счётчике на каждую секунду до конца накатки.
func New(dataDir string) (*pb.PocketBase, error) {
app := pb.NewWithConfig(pb.Config{
DefaultDataDir: dataDir,
HideStartBanner: true,
})
if err := app.Bootstrap(); err != nil {
return nil, fmt.Errorf("failed to bootstrap storage: %w", err)
}
if err := app.RunAllMigrations(); err != nil {
return nil, fmt.Errorf("failed to apply storage schema: %w", err)
}
return app, nil
}
// MustFindCollection достаёт коллекцию по имени. Отсутствие коллекции здесь —
// не отказ окружения, а несделанный шаг схемы: сервис до этой точки не доходит,
// потому что Serve накатывает схему прежде, чем поднять сервер.
func findCollection(app core.App, name string) (*core.Collection, error) {
collection, err := app.FindCollectionByNameOrId(name)
if err != nil {
return nil, fmt.Errorf("failed to find collection %s: %w", name, err)
}
return collection, nil
}
@@ -0,0 +1,269 @@
package pocketbase
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/filesystem"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// workFile — рабочая копия файла на диске. Живёт во временном каталоге
// системы, а не в каталоге данных: последний смонтирован на сервере, и
// временному там не место.
type workFile struct {
path string
}
func (w *workFile) Path() string { return w.path }
func (w *workFile) Size() (int64, error) {
info, err := os.Stat(w.path)
if err != nil {
return 0, fmt.Errorf("failed to stat work file: %w", err)
}
return info.Size(), nil
}
// Close убирает копию. Отсутствие файла отказом не считается: шаг мог не дойти
// до его создания, и повторный Close тоже законен.
func (w *workFile) Close() error {
if err := os.Remove(w.path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove work file: %w", err)
}
return nil
}
type FileRepository struct {
app core.App
}
func NewFileRepository(app core.App) *FileRepository {
return &FileRepository{app: app}
}
// newWorkFile заводит пустую копию во временном каталоге. Расширение сохраняется
// в имени: `ffprobe` и `ffmpeg` по нему выбирают разбор.
func newWorkFile(ext string) (*workFile, error) {
f, err := os.CreateTemp("", "transcriber-*"+ext)
if err != nil {
return nil, fmt.Errorf("failed to create work file: %w", err)
}
path := f.Name()
if err := f.Close(); err != nil {
_ = os.Remove(path)
return nil, fmt.Errorf("failed to close work file: %w", err)
}
return &workFile{path: path}, nil
}
func (repo *FileRepository) StageEmpty(ext string) (contract.WorkFile, error) {
return newWorkFile(ext)
}
func (repo *FileRepository) Stage(ext string, content io.Reader) (contract.WorkFile, error) {
work, err := newWorkFile(ext)
if err != nil {
return nil, err
}
if err := writeTo(work.path, content); err != nil {
// Отказ уборки не подменяет отказ записи, но и не теряется.
return nil, errors.Join(err, work.Close())
}
return work, nil
}
func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
record, err := repo.app.FindRecordById(FilesCollection, fileID)
if err != nil {
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
}
name := firstFileName(record)
if name == "" {
return nil, fmt.Errorf("file %s has no content in storage", fileID)
}
work, err := newWorkFile(filepath.Ext(name))
if err != nil {
return nil, err
}
src, err := repo.openStored(record, name)
if err != nil {
return nil, errors.Join(err, work.Close())
}
defer src.Close()
if err := writeTo(work.path, src); err != nil {
return nil, errors.Join(err, work.Close())
}
return work, nil
}
// CreateLocal кладёт рабочую копию в хранилище. Имя задаём мы: умолчание
// библиотеки строит его из имени, данного отправителем, а имя отправителя в
// хранилище не попадает — путь к файлу читается в журнале, и инвариант
// приватности этого не допускает. Свой суффикс хранилище допишет само.
func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*entity.File, error) {
collection, err := findCollection(repo.app, FilesCollection)
if err != nil {
return nil, err
}
stored, err := filesystem.NewFileFromPath(work.Path())
if err != nil {
return nil, fmt.Errorf("failed to read work file: %w", err)
}
stored.Name = name
record := core.NewRecord(collection)
record.Set("file", stored)
record.Set("location", entity.LocationLocal)
record.Set("size", stored.Size)
if err := repo.app.Save(record); err != nil {
// Отказ укладки называет имя файла — то самое, из которого строится
// ссылка на скачивание. В цепочку оно не идёт по той же причине, что и
// ключ при чтении.
return nil, errors.New("failed to store file")
}
return recordToFile(record), nil
}
func (repo *FileRepository) CreateRemote(objectKey string, size int64) (*entity.File, error) {
collection, err := findCollection(repo.app, FilesCollection)
if err != nil {
return nil, err
}
record := core.NewRecord(collection)
record.Set("location", entity.LocationS3)
record.Set("object_key", objectKey)
record.Set("size", size)
if err := repo.app.Save(record); err != nil {
return nil, fmt.Errorf("failed to store remote file record: %w", err)
}
return recordToFile(record), nil
}
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
record, err := repo.app.FindRecordById(FilesCollection, id)
if err != nil {
return nil, fmt.Errorf("failed to get file: %w", err)
}
return recordToFile(record), nil
}
func (repo *FileRepository) Open(fileID string) (io.ReadCloser, error) {
record, err := repo.app.FindRecordById(FilesCollection, fileID)
if err != nil {
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
}
name := firstFileName(record)
if name == "" {
return nil, fmt.Errorf("file %s has no content in storage", fileID)
}
return repo.openStored(record, name)
}
// openStored открывает содержимое файла в хранилище потоком.
func (repo *FileRepository) openStored(record *core.Record, name string) (io.ReadCloser, error) {
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() + "/" + name)
if err != nil {
// Отказ хранилища несёт ключ файла целиком, а ключ — последняя часть
// ссылки `/api/files/...`, по которой запись скачивают. Наружу отдаётся
// идентификатор записи, и только он: цепочка `%w` уехала бы в журнал и
// стала бы там бессрочным ключом к чужому аудио.
return nil, errors.Join(
fmt.Errorf("failed to read stored file of record %s", record.Id),
fsys.Close(),
)
}
return &storedReader{reader: reader, fsys: fsys}, nil
}
// storedReader держит открытой файловую систему хранилища на всё время чтения:
// закрытая раньше времени, она обрывает поток на середине записи.
type storedReader struct {
reader io.ReadCloser
fsys io.Closer
}
func (r *storedReader) Read(p []byte) (int, error) { return r.reader.Read(p) }
func (r *storedReader) Close() error {
readerErr := r.reader.Close()
fsysErr := r.fsys.Close()
switch {
case readerErr != nil && fsysErr != nil:
return errors.New("failed to close stored file and its filesystem")
case readerErr != nil:
return errors.New("failed to close stored file")
default:
return fsysErr
}
}
// writeTo переливает содержимое в файл потоком. В память запись целиком не
// читается: расчётный потолок — шесть часов.
func writeTo(path string, content io.Reader) error {
dst, err := os.Create(path)
if err != nil {
return fmt.Errorf("failed to open work file: %w", err)
}
if _, err := io.Copy(dst, content); err != nil {
_ = dst.Close()
return fmt.Errorf("failed to write work file: %w", err)
}
if err := dst.Close(); err != nil {
return fmt.Errorf("failed to close work file: %w", err)
}
return nil
}
func firstFileName(record *core.Record) string {
names := record.GetStringSlice("file")
if len(names) == 0 {
return ""
}
return names[0]
}
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(),
}
}
@@ -0,0 +1,38 @@
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, "объявленный потолок выше проверяемого размера")
}
@@ -0,0 +1,203 @@
package pocketbase
import (
"database/sql"
"time"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Отображение задачи в запись коллекции и обратно живёт одним местом. Прежде
// список колонок был переписан четырежды — в каждом запросе своего слоя, — и
// расхождение проявлялось как потерянное при сохранении поле.
// applyOwnedByPipeline кладёт в запись только те поля, которыми распоряжается
// конвейер. Поля, которые он не меняет никогда — куда отвечать отправителю и
// каким входом пришла запись, — не трогаются вовсе.
//
// Разрез нужен потому, что шаг держит задачу снимком с момента захвата и до
// своего сохранения, а это до восьми часов. Всё, что владелец правил в панели за
// это время, безусловная запись снимка стёрла бы молча: ни строки в журнале, ни
// отказа в панели — владелец видел бы успешное сохранение и был бы уверен, что
// правка на месте.
func applyOwnedByPipeline(record *core.Record, job *entity.TranscribeJob) {
record.Set("state", job.State)
record.Set("file", derefString(job.FileID))
record.Set("error_text", derefString(job.ErrorText))
record.Set("acquisition_id", derefString(job.AcquisitionID))
record.Set("acquire_time", dateOrEmpty(job.AcquireTime))
record.Set("delay_time", dateOrEmpty(job.DelayTime))
record.Set("attempts", job.Attempts)
record.Set("recognition_op_id", derefString(job.RecognitionOpID))
record.Set("transcription_text", derefString(job.TranscriptionText))
}
// applyToRecord кладёт задачу в запись целиком — это заведение, и спорить за
// поля здесь не с кем.
func applyToRecord(record *core.Record, job *entity.TranscribeJob) {
applyOwnedByPipeline(record, job)
record.Set("source", job.Source)
record.Set("tg_chat_id", derefInt64(job.TgChatId))
record.Set("tg_reply_message_id", derefInt(job.TgReplyMessageId))
}
func recordToJob(record *core.Record) *entity.TranscribeJob {
return &entity.TranscribeJob{
Id: record.Id,
State: record.GetString("state"),
Source: record.GetString("source"),
FileID: nilIfEmpty(record.GetString("file")),
ErrorText: nilIfEmpty(record.GetString("error_text")),
AcquisitionID: nilIfEmpty(record.GetString("acquisition_id")),
AcquireTime: timeOrNil(record.GetDateTime("acquire_time")),
DelayTime: timeOrNil(record.GetDateTime("delay_time")),
Attempts: record.GetInt("attempts"),
RecognitionOpID: nilIfEmpty(record.GetString("recognition_op_id")),
TranscriptionText: nilIfEmpty(record.GetString("transcription_text")),
TgChatId: nilIfZero64(int64(record.GetInt("tg_chat_id"))),
TgReplyMessageId: nilIfZeroInt(record.GetInt("tg_reply_message_id")),
CreatedAt: record.GetDateTime("created").Time(),
UpdatedAt: record.GetDateTime("updated").Time(),
}
}
// acquiredRow — задача, прочитанная сырым запросом захвата. Колонки читаются
// именно так, потому что запрос идёт мимо записей коллекции; связь с их
// перечнем держит константа acquireColumns и тест захвата, читающий задачу
// целиком.
type acquiredRow struct {
Id string `db:"id"`
State string `db:"state"`
Source string `db:"source"`
FileID sql.NullString `db:"file"`
ErrorText sql.NullString `db:"error_text"`
AcquisitionID sql.NullString `db:"acquisition_id"`
AcquireTime sql.NullString `db:"acquire_time"`
DelayTime sql.NullString `db:"delay_time"`
Attempts int `db:"attempts"`
RecognitionOpID sql.NullString `db:"recognition_op_id"`
TranscriptionText sql.NullString `db:"transcription_text"`
TgChatId sql.NullInt64 `db:"tg_chat_id"`
TgReplyMessageId sql.NullInt64 `db:"tg_reply_message_id"`
Created sql.NullString `db:"created"`
Updated sql.NullString `db:"updated"`
}
func (r *acquiredRow) toJob() *entity.TranscribeJob {
job := &entity.TranscribeJob{
Id: r.Id,
State: r.State,
Source: r.Source,
FileID: nullToPtr(r.FileID),
ErrorText: nullToPtr(r.ErrorText),
AcquisitionID: nullToPtr(r.AcquisitionID),
AcquireTime: parseTimeOrNil(r.AcquireTime),
DelayTime: parseTimeOrNil(r.DelayTime),
Attempts: r.Attempts,
RecognitionOpID: nullToPtr(r.RecognitionOpID),
TranscriptionText: nullToPtr(r.TranscriptionText),
}
if r.TgChatId.Valid && r.TgChatId.Int64 != 0 {
chatId := r.TgChatId.Int64
job.TgChatId = &chatId
}
if r.TgReplyMessageId.Valid && r.TgReplyMessageId.Int64 != 0 {
msgId := int(r.TgReplyMessageId.Int64)
job.TgReplyMessageId = &msgId
}
if created := parseTimeOrNil(r.Created); created != nil {
job.CreatedAt = *created
}
if updated := parseTimeOrNil(r.Updated); updated != nil {
job.UpdatedAt = *updated
}
return job
}
func derefString(v *string) string {
if v == nil {
return ""
}
return *v
}
func derefInt64(v *int64) int64 {
if v == nil {
return 0
}
return *v
}
func derefInt(v *int) int {
if v == nil {
return 0
}
return *v
}
// dateOrEmpty отдаёт пустое значение вместо нулевой даты: пустая колонка даты в
// хранилище это пустая строка, и она же значит «времени нет».
func dateOrEmpty(v *time.Time) any {
if v == nil {
return ""
}
date, err := types.ParseDateTime(*v)
if err != nil {
return ""
}
return date
}
func nilIfEmpty(v string) *string {
if v == "" {
return nil
}
return &v
}
func timeOrNil(v types.DateTime) *time.Time {
if v.IsZero() {
return nil
}
t := v.Time()
return &t
}
func nilIfZero64(v int64) *int64 {
if v == 0 {
return nil
}
return &v
}
func nilIfZeroInt(v int) *int {
if v == 0 {
return nil
}
return &v
}
func nullToPtr(v sql.NullString) *string {
if !v.Valid || v.String == "" {
return nil
}
s := v.String
return &s
}
func parseTimeOrNil(v sql.NullString) *time.Time {
if !v.Valid || v.String == "" {
return nil
}
date, err := types.ParseDateTime(v.String)
if err != nil || date.IsZero() {
return nil
}
t := date.Time()
return &t
}
@@ -0,0 +1,122 @@
package pocketbase
import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Схема заводится версионированными шагами, и применённый шаг не переписывается
// — только новым шагом. Инвариант проекта перенесён дословно: хранилище считает
// применённое по имени файла шага.
//
// Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его
// `apis.Serve` прежде, чем поднять сервер.
func init() {
migrations.Register(up202608110001, down202608110001, "202608110001_init.go")
}
func up202608110001(app core.App) error {
files := core.NewBaseCollection(FilesCollection)
files.Fields.Add(
// Сам файл. Защищённым поле не помечено намеренно: право прочитать
// запись даёт знание её идентификатора, и файл встаёт вровень с опросом
// готовности задачи, а не ниже.
//
// Потолок задан **числом**: нулём библиотека читает не «без предела», а
// своё умолчание в 5 МиБ, и на нём отваливалось бы всё длиннее пяти
// минут. Число выведено из расчётного потолка записи в шесть часов с
// запасом на видео; оно же стоит строкой в docs/database.md.
&core.FileField{Name: "file", MaxSelect: 1, MaxSize: entity.MaxRecordSize},
// Где лежит копия. Поле названо `location`, а не `storage`: последним
// словом зовут само хранилище, и третий смысл развёл бы одно слово по
// разным вещам.
&core.SelectField{
Name: "location",
Values: []string{entity.LocationLocal, entity.LocationS3},
MaxSelect: 1,
Required: true,
},
// Ключ объекта во внешнем хранилище; у местной копии пуст.
&core.TextField{Name: "object_key"},
&core.NumberField{Name: "size", OnlyInt: true},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
if err := app.Save(files); err != nil {
return err
}
jobs := core.NewBaseCollection(JobsCollection)
jobs.Fields.Add(
// Перечень состояний закрыт схемой: задача, заведённая в панели руками,
// не должна попасть в выборку с состоянием, которого конвейер не знает.
&core.SelectField{
Name: "state",
Values: []string{
entity.StateCreated,
entity.StateConverted,
entity.StateTranscribe,
entity.StateDone,
entity.StateFailed,
entity.StateDead,
},
MaxSelect: 1,
Required: true,
},
&core.SelectField{
Name: "source",
Values: []string{entity.SourceUnknown, entity.SourceApi, entity.SourceTelegram},
MaxSelect: 1,
Required: true,
},
// Текущий файл задачи: шаг конвейера переставляет ссылку на свой
// результат.
// Обязательна: задача без записи не может пройти ни одного шага, и
// заведённая в панели руками она дошла бы до шага только затем, чтобы
// отказать. Компилятор этого не держит — держит схема.
&core.RelationField{
Name: "file",
CollectionId: files.Id,
MaxSelect: 1,
Required: true,
},
&core.TextField{Name: "error_text"},
&core.TextField{Name: "acquisition_id"},
&core.DateField{Name: "acquire_time"},
&core.DateField{Name: "delay_time"},
// Число попыток: растёт при каждом захвате, обнуляется на шаге,
// завершившемся без отказа.
&core.NumberField{Name: "attempts", OnlyInt: true, Min: ptr(0.0)},
&core.TextField{Name: "recognition_op_id"},
&core.EditorField{Name: "transcription_text"},
&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},
)
// Выборка воркера идёт по состоянию, паузе и сроку захвата — индекс по
// состоянию снимает полный перебор, который был у прежней таблицы.
jobs.AddIndex("idx_transcribe_jobs_state", false, "state", "")
return app.Save(jobs)
}
func down202608110001(app core.App) error {
// Порядок обратный порядку заведения: задачи ссылаются на файлы.
for _, name := range []string{JobsCollection, FilesCollection} {
collection, err := app.FindCollectionByNameOrId(name)
if err != nil {
continue
}
if err := app.Delete(collection); err != nil {
return err
}
}
return nil
}
func ptr[T any](v T) *T { return &v }
+36
View File
@@ -0,0 +1,36 @@
package pocketbase
import (
"github.com/pocketbase/pocketbase/core"
)
// BindPanelRules подчиняет правку задачи в панели тем же правилам перехода, что
// и правку из кода.
//
// Панель — вход в задачу наравне с конвейером, а не окно просмотра: ради правки
// она и покупалась, мёртвая задача оживляется сменой состояния. Но правка полем
// идёт мимо кода, который чистит служебные поля прошлого состояния, и владелец,
// «вернувший задачу в работу», получил бы задачу с прежним признаком захвата
// (захвату она не выдастся до конца срока) и с числом попыток на пределе (умрёт
// от первого же отказа). Узнать об этом ему неоткуда.
//
// Хук стоит на правке **запросом**, а не на всяком сохранении записи. Модельное
// событие не различает, кто пишет, и срабатывало бы на каждом переходе
// конвейера: тогда задержка, поставленная шагом вместе со сменой состояния,
// стиралась бы тем же сохранением, а число попыток мёртвой задачи — которое
// переход хранит намеренно — приходило бы владельцу нулём.
func BindPanelRules(app core.App) {
app.OnRecordUpdateRequest(JobsCollection).BindFunc(func(e *core.RecordRequestEvent) error {
original := e.Record.Original()
if original == nil || original.GetString("state") == e.Record.GetString("state") {
return e.Next()
}
e.Record.Set("acquisition_id", "")
e.Record.Set("acquire_time", "")
e.Record.Set("delay_time", "")
e.Record.Set("attempts", 0)
return e.Next()
})
}
@@ -0,0 +1,147 @@
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"
)
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, 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(JobsCollection, job.Id)
if err != nil {
return fmt.Errorf("failed to find transcribe job: %w", err)
}
if holder != "" && record.GetString("acquisition_id") != holder {
return &contract.LostAcquisitionError{JobID: job.Id}
}
// Кладём только то, чем распоряжается конвейер: правку владельца в
// панели снимок шага стирать не должен.
applyOwnedByPipeline(record, job)
if err := txApp.Save(record); err != nil {
return fmt.Errorf("failed to update transcribe job: %w", err)
}
job.UpdatedAt = record.GetDateTime("updated").Time()
return nil
})
if err != nil {
return err
}
return nil
}
func (repo *TranscriptJobRepository) GetByID(id string) (*entity.TranscribeJob, error) {
record, err := repo.app.FindRecordById(JobsCollection, id)
if err != nil {
return nil, fmt.Errorf("failed to get transcribe job: %w", err)
}
return recordToJob(record), nil
}
// Колонки, которые читает захват. Список нужен запросу дословно: `RETURNING *`
// отдал бы и порядок, зависящий от схемы.
const acquireColumns = `id, state, source, file, error_text, acquisition_id, ` +
`acquire_time, delay_time, attempts, recognition_op_id, transcription_text, ` +
`tg_chat_id, tg_reply_message_id, created, updated`
// FindAndAcquire забирает задачу одним неделимым шагом: выбор подходящей и
// пометка её захваченной идут вместе, и захваченная возвращается тем же
// запросом. Двум вызывающим, пришедшим за одним состоянием, запись достаётся
// одному — на этом стоит инвариант «Принятая запись не теряется молча».
//
// Запрос идёт сырым, мимо записей коллекции: `app.DB()` направляет всё, кроме
// выборок, в пул с единственным соединением, и захваты выстраиваются в очередь.
// Хуки коллекции на нём не срабатывают, поэтому время изменения проставляет сам
// запрос.
//
// Все времена кладутся и сравниваются тем же видом, каким хранилище пишет свои
// `created`/`updated`: сравнение строк побайтово, и вид, разошедшийся хоть
// разделителем, обратил бы условие срока в постоянную истину или постоянную
// ложь — молча.
func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error) {
now := types.NowDateTime()
query := repo.app.DB().NewQuery(`
UPDATE {{` + JobsCollection + `}}
SET acquisition_id = {:acquisition_id},
acquire_time = {:now},
attempts = attempts + 1,
updated = {:now}
WHERE id = (
SELECT id FROM {{` + 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 aquire job with state %s: %w", state, err)
}
return row.toJob(), nil
}
@@ -0,0 +1,410 @@
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"
)
// 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(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(JobsCollection, mine.Id)
require.NoError(t, err)
record.Set("acquisition_id", "someone-else")
require.NoError(t, app.Save(record))
mine.MoveToState(entity.StateConverted)
err = repo.Save(mine, "mine")
var lost *contract.LostAcquisitionError
require.ErrorAs(t, err, &lost)
// И состояние не поехало.
after, err := repo.GetByID(mine.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateCreated, after.State)
}
// Пустой держатель значит «задача не захватывалась» — так её сохраняет приём.
func TestSave_WithoutHolderWritesAnyway(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateCreated)
job.MoveToState(entity.StateConverted)
require.NoError(t, repo.Save(job, ""))
after, err := repo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateConverted, after.State)
}
// Правка состояния **запросом** — то есть из панели — чистит служебные поля
// прошлого состояния: те же, что чистит переход из кода. Иначе владелец,
// вернувший мёртвую задачу в работу, получил бы задачу, которая не выдаётся
// захвату и умирает от первого же отказа, и не узнал бы об этом.
func TestPanelRules_StateChangeByRequestClearsAcquisition(t *testing.T) {
app := newTestApp(t)
BindPanelRules(app)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateCreated)
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
require.NoError(t, err)
require.NotNil(t, acquired.AcquisitionID)
record, err := app.FindRecordById(JobsCollection, job.Id)
require.NoError(t, err)
record.Set("attempts", 5)
record.Set("state", entity.StateDead)
require.NoError(t, app.Save(record))
// Владелец возвращает задачу в работу правкой состояния в панели — то есть
// запросом к записи, а не сохранением из кода.
patchRecord(t, app, job.Id, `{"state":"`+entity.StateCreated+`"}`)
after, err := repo.GetByID(job.Id)
require.NoError(t, err)
assert.Nil(t, after.AcquisitionID, "признак захвата снят")
assert.Nil(t, after.AcquireTime, "время захвата снято")
assert.Nil(t, after.DelayTime, "пауза снята")
assert.Equal(t, 0, after.Attempts, "число попыток обнулено")
// И ближайший захват задачу выдаёт.
again, err := repo.FindAndAcquire(entity.StateCreated, "next", time.Now().Add(-time.Hour))
require.NoError(t, err)
assert.Equal(t, job.Id, again.Id)
}
// Обратная сторона того же правила, и она дороже: правила панели MUST не
// трогать записи, которые правит сам конвейер. Модельный хук их не различал, и
// пауза, поставленная шагом вместе со сменой состояния, стиралась тем же
// сохранением, а число попыток мёртвой задачи приходило владельцу нулём.
func TestPanelRules_DoNotTouchPipelineWrites(t *testing.T) {
app := newTestApp(t)
BindPanelRules(app)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateConverted)
acquired, err := repo.FindAndAcquire(entity.StateConverted, "holder", time.Now().Add(-time.Hour))
require.NoError(t, err)
// Шаг ставит задержку опроса вместе со сменой состояния.
delay := time.Now().Add(10 * time.Second)
acquired.MoveToStateAndDelay(entity.StateTranscribe, &delay)
require.NoError(t, repo.Save(acquired, "holder"))
after, err := repo.GetByID(job.Id)
require.NoError(t, err)
require.NotNil(t, after.DelayTime, "задержка, поставленная шагом, пережила сохранение")
// Переход в «мертва» хранит число попыток намеренно: по нему владелец видит,
// сколько раз мы пробовали.
after.Attempts = 6
after.Die("attempts exhausted: 6")
require.NoError(t, repo.Save(after, ""))
dead, err := repo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateDead, dead.State)
assert.Equal(t, 6, dead.Attempts, "число попыток мёртвой задачи сохранено")
}
// patchRecord правит запись тем же путём, каким её правит панель: запросом к
// API от имени владельца.
func patchRecord(t *testing.T, app core.App, recordID, body string) {
t.Helper()
superusers, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
require.NoError(t, err)
owner := core.NewRecord(superusers)
owner.Set("email", "owner@example.com")
owner.Set("password", "ownerpassword123")
require.NoError(t, app.Save(owner))
token, err := owner.NewStaticAuthToken(time.Hour)
require.NoError(t, err)
router, err := apis.NewRouter(app)
require.NoError(t, err)
mux, err := router.BuildMux()
require.NoError(t, err)
req := httptest.NewRequest(
http.MethodPatch,
"/api/collections/"+JobsCollection+"/records/"+recordID,
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", token)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, "правка записи владельцем: %s", w.Body.String())
}
// Правка владельца в панели переживает сохранение шага. Шаг держит задачу
// снимком с момента захвата и до своего сохранения — до восьми часов, — и
// безусловная запись снимка стёрла бы правку молча: ни строки в журнале, ни
// отказа в панели.
func TestSave_KeepsOwnerEditMadeWhileStepHeldTheJob(t *testing.T) {
app := newTestApp(t)
BindPanelRules(app)
repo := NewTranscriptJobRepository(app)
file := newFile(t, app)
chatId := int64(111)
job := &entity.TranscribeJob{
State: entity.StateCreated,
Source: entity.SourceTelegram,
FileID: &file.Id,
TgChatId: &chatId,
}
require.NoError(t, repo.Create(job))
// Шаг захватил задачу и работает.
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
require.NoError(t, err)
// Владелец правит в панели поле, которого конвейер не касается.
patchRecord(t, app, job.Id, `{"tg_chat_id":999999}`)
// Шаг доработал и сохраняет свой снимок.
acquired.MoveToState(entity.StateConverted)
require.NoError(t, repo.Save(acquired, "holder"))
after, err := repo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateConverted, after.State, "шаг свой результат записал")
require.NotNil(t, after.TgChatId)
assert.Equal(t, int64(999999), *after.TgChatId, "правка владельца пережила сохранение шага")
}
-56
View File
@@ -1,56 +0,0 @@
package sqlite
import (
"database/sql"
"fmt"
"git.vakhrushev.me/av/transcriber/internal/entity"
"github.com/doug-martin/goqu/v9"
)
type FileRepository struct {
db *sql.DB
gq *goqu.Database
}
func NewFileRepository(conn *sql.DB, gq *goqu.Database) *FileRepository {
return &FileRepository{conn, gq}
}
func (repo *FileRepository) Create(file *entity.File) error {
record := goqu.Record{
"id": file.Id,
"storage": file.Storage,
"file_name": file.FileName,
"size": file.Size,
"created_at": file.CreatedAt,
}
query := repo.gq.Insert("files").Rows(record)
sql, args, err := query.ToSQL()
if err != nil {
return fmt.Errorf("failed to build query: %w", err)
}
_, err = repo.db.Exec(sql, args...)
if err != nil {
return fmt.Errorf("failed to insert file: %w", err)
}
return nil
}
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
query := repo.gq.From("files").Select("id", "storage", "file_name", "size", "created_at").Where(goqu.C("id").Eq(id))
sql, args, err := query.ToSQL()
if err != nil {
return nil, fmt.Errorf("failed to build query: %w", err)
}
var file entity.File
err = repo.db.QueryRow(sql, args...).Scan(&file.Id, &file.Storage, &file.FileName, &file.Size, &file.CreatedAt)
if err != nil {
return nil, fmt.Errorf("failed to get file: %w", err)
}
return &file, nil
}
@@ -1,230 +0,0 @@
package sqlite
import (
"database/sql"
"fmt"
"time"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
goqu "github.com/doug-martin/goqu/v9"
)
type TranscriptJobRepository struct {
db *sql.DB
gq *goqu.Database
}
func NewTranscriptJobRepository(db *sql.DB, gq *goqu.Database) *TranscriptJobRepository {
return &TranscriptJobRepository{db, gq}
}
func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
record := goqu.Record{
"id": job.Id,
"state": job.State,
"source": job.Source,
"file_id": job.FileID,
"is_error": job.IsError,
"error_text": job.ErrorText,
"acquisition_id": job.AcquisitionID,
"acquire_time": job.AcquireTime,
"delay_time": job.DelayTime,
"recognition_op_id": job.RecognitionOpID,
"transcription_text": job.TranscriptionText,
"tg_chat_id": job.TgChatId,
"tg_reply_message_id": job.TgReplyMessageId,
"created_at": job.CreatedAt,
"updated_at": job.UpdatedAt,
}
query := repo.gq.Insert("transcribe_jobs").Rows(record)
sql, args, err := query.ToSQL()
if err != nil {
return fmt.Errorf("failed to build query: %w", err)
}
_, err = repo.db.Exec(sql, args...)
if err != nil {
return fmt.Errorf("failed to insert transcribe job: %w", err)
}
return nil
}
func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob) error {
record := goqu.Record{
"state": job.State,
"source": job.Source,
"file_id": job.FileID,
"is_error": job.IsError,
"error_text": job.ErrorText,
"acquisition_id": job.AcquisitionID,
"acquire_time": job.AcquireTime,
"delay_time": job.DelayTime,
"recognition_op_id": job.RecognitionOpID,
"transcription_text": job.TranscriptionText,
"tg_chat_id": job.TgChatId,
"tg_reply_message_id": job.TgReplyMessageId,
"updated_at": job.UpdatedAt,
}
query := repo.gq.Update("transcribe_jobs").Set(record).Where(goqu.C("id").Eq(job.Id))
sql, args, err := query.ToSQL()
if err != nil {
return fmt.Errorf("failed to build query: %w", err)
}
_, err = repo.db.Exec(sql, args...)
if err != nil {
return fmt.Errorf("failed to update transcribe job: %w", err)
}
return nil
}
func (repo *TranscriptJobRepository) GetByID(id string) (*entity.TranscribeJob, error) {
query := repo.gq.From("transcribe_jobs").Select(
"id",
"state",
"source",
"file_id",
"is_error",
"error_text",
"acquisition_id",
"acquire_time",
"delay_time",
"recognition_op_id",
"transcription_text",
"tg_chat_id",
"tg_reply_message_id",
"created_at",
"updated_at",
).Where(goqu.C("id").Eq(id))
sql, args, err := query.ToSQL()
if err != nil {
return nil, fmt.Errorf("failed to build query: %w", err)
}
var job entity.TranscribeJob
err = repo.db.QueryRow(sql, args...).Scan(
&job.Id,
&job.State,
&job.Source,
&job.FileID,
&job.IsError,
&job.ErrorText,
&job.AcquisitionID,
&job.AcquireTime,
&job.DelayTime,
&job.RecognitionOpID,
&job.TranscriptionText,
&job.TgChatId,
&job.TgReplyMessageId,
&job.CreatedAt,
&job.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to get transcribe job: %w", err)
}
return &job, nil
}
func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error) {
updateQuery := repo.gq.Update("transcribe_jobs").
Set(
goqu.Record{
"acquisition_id": acquisitionId,
"acquire_time": time.Now(),
},
).
Where(
goqu.C("id").Eq(
repo.gq.From("transcribe_jobs").Select("id").
Where(
goqu.And(
goqu.C("state").Eq(state),
goqu.C("is_error").Eq(0),
goqu.Or(
goqu.C("delay_time").IsNull(),
goqu.C("delay_time").Lt(time.Now()),
),
goqu.Or(
goqu.C("acquisition_id").IsNull(),
goqu.C("acquire_time").Lt(rottingTime),
),
),
).
Limit(1),
),
)
sql, args, err := updateQuery.ToSQL()
if err != nil {
return nil, fmt.Errorf("failed to build query: %w", err)
}
// log.Printf("aquire sql: %s", sql)
result, err := repo.db.Exec(sql, args...)
if err != nil {
return nil, fmt.Errorf("failed to aquire job with state %s: %w", state, err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return nil, fmt.Errorf("failed check affected rows: %w", err)
}
if rowsAffected == 0 {
e := contract.JobNotFoundError{State: state, Message: "appropriate job not found"}
return nil, &e
}
if rowsAffected != 1 {
return nil, fmt.Errorf("unexpected affected rows count: %d", rowsAffected)
}
selectQuery := repo.gq.From("transcribe_jobs").Select(
"id",
"state",
"source",
"file_id",
"is_error",
"error_text",
"acquisition_id",
"acquire_time",
"delay_time",
"recognition_op_id",
"transcription_text",
"tg_chat_id",
"tg_reply_message_id",
"created_at",
"updated_at",
).Where(goqu.C("acquisition_id").Eq(acquisitionId))
sql, args, err = selectQuery.ToSQL()
if err != nil {
return nil, fmt.Errorf("failed to build query: %w", err)
}
var job entity.TranscribeJob
err = repo.db.QueryRow(sql, args...).Scan(
&job.Id,
&job.State,
&job.Source,
&job.FileID,
&job.IsError,
&job.ErrorText,
&job.AcquisitionID,
&job.AcquireTime,
&job.DelayTime,
&job.RecognitionOpID,
&job.TranscriptionText,
&job.TgChatId,
&job.TgReplyMessageId,
&job.CreatedAt,
&job.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to get transcribe job: %w", err)
}
return &job, nil
}
+4 -10
View File
@@ -9,7 +9,6 @@ import (
type Config struct { type Config struct {
Server ServerConfig `toml:"server"` Server ServerConfig `toml:"server"`
Database DatabaseConfig `toml:"database"`
Storage StorageConfig `toml:"storage"` Storage StorageConfig `toml:"storage"`
Yandex YandexConfig `toml:"yandex"` Yandex YandexConfig `toml:"yandex"`
Telegram TelegramConfig `toml:"telegram"` Telegram TelegramConfig `toml:"telegram"`
@@ -22,12 +21,10 @@ type ServerConfig struct {
UsersWhiteList []string `toml:"users_while_list"` UsersWhiteList []string `toml:"users_while_list"`
} }
type DatabaseConfig struct { // StorageConfig — единственный каталог данных: под ним лежат и база, и файлы
Path string `toml:"path"` // записей. Двух путей, как было раньше, у хранилища не бывает.
}
type StorageConfig struct { type StorageConfig struct {
Path string `toml:"path"` DataDir string `toml:"data_dir"`
} }
type YandexConfig struct { type YandexConfig struct {
@@ -53,11 +50,8 @@ func defaultConfig() *Config {
ShutdownTimeout: 5, ShutdownTimeout: 5,
ForceShutdownTimeout: 20, ForceShutdownTimeout: 20,
}, },
Database: DatabaseConfig{
Path: "data/transcriber.db",
},
Storage: StorageConfig{ Storage: StorageConfig{
Path: "data/files", DataDir: "data",
}, },
Yandex: YandexConfig{ Yandex: YandexConfig{
FolderID: "", FolderID: "",
+12
View File
@@ -11,6 +11,18 @@ func (e *JobNotFoundError) Error() string {
return fmt.Sprintf("%s - %s", e.State, e.Message) return fmt.Sprintf("%s - %s", e.State, e.Message)
} }
// LostAcquisitionError — захват задачи за время работы шага достался другому.
// Шаг, получивший его, завершается без записи результата и без ответа
// отправителю: иначе два воркера пишут в одну задачу по очереди, а отправитель
// получает два ответа на одну запись.
type LostAcquisitionError struct {
JobID string
}
func (e *LostAcquisitionError) Error() string {
return fmt.Sprintf("%s: job acquisition lost", e.JobID)
}
type NoopJobError struct { type NoopJobError struct {
State string State string
} }
+41 -2
View File
@@ -1,19 +1,58 @@
package contract package contract
import ( import (
"io"
"time" "time"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
) )
// WorkFile — рабочая копия файла на диске: её просят шаги, отдающие файл
// внешней программе, потому что `ffmpeg` и `ffprobe` принимают имя аргументом.
//
// Заводится копия одним способом — репозиторием файлов, — и убирает её за собой
// Close. Каждый шаг, заводящий копию сам, повторял бы и обязанность прибрать, а
// забытая копия это шестичасовая запись во временном каталоге, о которой не
// узнает никто.
type WorkFile interface {
// Path — имя копии на диске, годное для внешней программы.
Path() string
// Size — длина копии в байтах на момент вызова.
Size() (int64, error)
// Close убирает копию. Зовётся на любом исходе, включая отказ.
Close() error
}
type FileRepository interface { type FileRepository interface {
Create(file *entity.File) error // Stage принимает содержимое потоком в рабочую копию с заданным
// расширением: по нему внешняя программа выбирает разбор. В память запись
// целиком не читается — расчётный потолок шесть часов.
Stage(ext string, content io.Reader) (WorkFile, error)
// StageEmpty заводит пустую рабочую копию с заданным расширением — под
// результат внешней программы, которая пишет по имени.
StageEmpty(ext string) (WorkFile, error)
// Localize выдаёт рабочую копию хранимого файла.
Localize(fileID string) (WorkFile, error)
// CreateLocal кладёт рабочую копию в хранилище под именем name и заводит
// запись о файле. Имя задаёт сервис: умолчание хранилища, строящее его из
// имени отправителя, не применяется.
CreateLocal(name string, work WorkFile) (*entity.File, error)
// CreateRemote заводит запись о копии, лежащей во внешнем хранилище.
CreateRemote(objectKey string, size int64) (*entity.File, error)
GetByID(id string) (*entity.File, error) GetByID(id string) (*entity.File, error)
// Open отдаёт содержимое хранимого файла потоком.
Open(fileID string) (io.ReadCloser, error)
} }
type TranscriptJobRepository interface { type TranscriptJobRepository interface {
Create(job *entity.TranscribeJob) error Create(job *entity.TranscribeJob) error
Save(job *entity.TranscribeJob) error // Save сохраняет задачу, захват которой держит holder. Захват, доставшийся
// за время работы другому, даёт LostAcquisitionError и запись не проводит.
// Пустой holder снимает эту условность и в конвейере не употребляется: все
// его шаги получают признак захвата от FindAndAcquire.
Save(job *entity.TranscribeJob, holder string) error
GetByID(id string) (*entity.TranscribeJob, error) GetByID(id string) (*entity.TranscribeJob, error)
// FindAndAcquire забирает задачу одним неделимым шагом и увеличивает число
// её попыток. Работы в состоянии нет — JobNotFoundError.
FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error) FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error)
} }
+41 -51
View File
@@ -1,22 +1,30 @@
package http package http
import ( import (
"log" "log/slog"
"net/http" "net/http"
"time" "time"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/service" "git.vakhrushev.me/av/transcriber/internal/service"
"github.com/gin-gonic/gin"
) )
type TranscribeHandler struct { type TranscribeHandler struct {
jobRepo contract.TranscriptJobRepository jobRepo contract.TranscriptJobRepository
trsService *service.TranscribeService trsService *service.TranscribeService
logger *slog.Logger
} }
func NewTranscribeHandler(jobRepo contract.TranscriptJobRepository, trsService *service.TranscribeService) *TranscribeHandler { func NewTranscribeHandler(jobRepo contract.TranscriptJobRepository, trsService *service.TranscribeService, logger *slog.Logger) *TranscribeHandler {
return &TranscribeHandler{jobRepo: jobRepo, trsService: trsService} if logger == nil {
logger = slog.Default()
}
return &TranscribeHandler{jobRepo: jobRepo, trsService: trsService, logger: logger}
} }
type CreateTranscribeJobResponse struct { type CreateTranscribeJobResponse struct {
@@ -31,74 +39,56 @@ type GetTranscribeJobResponse struct {
TranscriptionText *string `json:"transcription_text,omitempty"` TranscriptionText *string `json:"transcription_text,omitempty"`
} }
func (h *TranscribeHandler) CreateTranscribeJob(c *gin.Context) { // Register вешает маршруты сервиса на роутер хранилища. Порт у сервиса и у
// панели один, поэтому и роутер один; имена полей ответа и коды при переезде
// сохранены — публичный контракт API объявлен необратимым.
func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) {
api := r.Group("/api")
// Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись
// раньше обработчика, без строки в журнале приёма. Приём размеру не судья,
// поэтому предел тела равен потолку самой записи.
api.POST("/audio", h.CreateTranscribeJob).Bind(apis.BodyLimit(entity.MaxRecordSize))
api.GET("/status/{id}", h.GetTranscribeJobStatus)
}
func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
// Получаем файл из формы // Получаем файл из формы
file, header, err := c.Request.FormFile("audio") file, header, err := e.Request.FormFile("audio")
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "No audio file provided"}) return e.JSON(http.StatusBadRequest, map[string]string{"error": "No audio file provided"})
return
} }
defer file.Close() defer func() {
if err := file.Close(); err != nil {
h.logger.Error("Failed to close uploaded file", "error", err)
}
}()
job, err := h.trsService.CreateJobFromApi(file, header.Filename) job, err := h.trsService.CreateJobFromApi(file, header.Filename)
if err != nil { if err != nil {
log.Printf("Err: %v", err) // Второй раз отказ не логируем: приём назван конвенцией логирующей
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create transcibe job"}) // границей и уже написал о нём. Транспорт переводит ошибку в ответ.
return return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create transcibe job"})
} }
// Возвращаем успешный ответ // Возвращаем успешный ответ
response := CreateTranscribeJobResponse{ return e.JSON(http.StatusCreated, CreateTranscribeJobResponse{
JobID: job.Id, JobID: job.Id,
State: job.State, State: job.State,
} })
c.JSON(http.StatusCreated, response)
} }
func (h *TranscribeHandler) GetTranscribeJobStatus(c *gin.Context) { func (h *TranscribeHandler) GetTranscribeJobStatus(e *core.RequestEvent) error {
jobID := c.Param("id") jobID := e.Request.PathValue("id")
job, err := h.jobRepo.GetByID(jobID) job, err := h.jobRepo.GetByID(jobID)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"}) return e.JSON(http.StatusNotFound, map[string]string{"error": "Job not found"})
return
} }
c.JSON(http.StatusOK, GetTranscribeJobResponse{ return e.JSON(http.StatusOK, GetTranscribeJobResponse{
JobID: job.Id, JobID: job.Id,
State: job.State, State: job.State,
CreatedAt: job.CreatedAt, CreatedAt: job.CreatedAt,
TranscriptionText: job.TranscriptionText, TranscriptionText: job.TranscriptionText,
}) })
} }
func (h *TranscribeHandler) RunConversionJob(c *gin.Context) {
err := h.trsService.FindAndRunConversionJob()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusOK)
}
func (h *TranscribeHandler) RunTranscribeJob(c *gin.Context) {
err := h.trsService.FindAndRunTranscribeJob()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusOK)
}
func (h *TranscribeHandler) RunRecognitionCheckJob(c *gin.Context) {
err := h.trsService.FindAndRunTranscribeCheckJob()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusOK)
}
+185 -184
View File
@@ -2,7 +2,6 @@ package http
import ( import (
"bytes" "bytes"
"database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -10,30 +9,22 @@ import (
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path"
"path/filepath"
"regexp" "regexp"
"runtime"
"strings" "strings"
"sync" "sync"
"testing" "testing"
"time"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer" "git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite" pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/service" "git.vakhrushev.me/av/transcriber/internal/service"
"github.com/doug-martin/goqu/v9"
_ "github.com/doug-martin/goqu/v9/dialect/sqlite3"
"github.com/gin-gonic/gin"
_ "github.com/mattn/go-sqlite3"
"github.com/pressly/goose/v3"
"github.com/prometheus/client_golang/prometheus"
sloggin "github.com/samber/slog-gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// Подставные адаптеры вместо ffprobe и ffmpeg. Проверки судят приём — что // Подставные адаптеры вместо ffprobe и ffmpeg. Проверки судят приём — что
@@ -72,21 +63,19 @@ func readableMetaViewer() *stubMetaViewer {
return &stubMetaViewer{seconds: 42} return &stubMetaViewer{seconds: 42}
} }
// testEnv — собранное окружение одной проверки. Каталог хранения свой у // testEnv — собранное окружение одной проверки. Каталог данных свой у каждой:
// каждой: рабочий каталог процесса проверки не трогают. // рабочий каталог процесса проверки не трогают.
type testEnv struct { type testEnv struct {
router *gin.Engine mux http.Handler
handler *TranscribeHandler handler *TranscribeHandler
db *sql.DB app core.App
storageDir string
journal *journalBuffer journal *journalBuffer
} }
// journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на // journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на
// пакет сделал бы исход функцией от соседних случаев — «поля на месте» прошло бы // пакет сделал бы исход функцией от соседних случаев — «поля на месте» прошло бы
// на чужой строке, а «маркера нет» покраснело бы от чужой. Замок нужен потому, // на чужой строке, а «маркера нет» покраснело бы от чужой. Замок нужен потому,
// что пишущих в него потоков три: логгер сервиса, стандартный `log` транспорта // что пишущих в него потоков два: логгер сервиса и логгер обработчика.
// и middleware запроса.
type journalBuffer struct { type journalBuffer struct {
mu sync.Mutex mu sync.Mutex
text strings.Builder text strings.Builder
@@ -106,44 +95,29 @@ func (b *journalBuffer) String() string {
return b.text.String() return b.text.String()
} }
func setupTestDB(t *testing.T) (*sql.DB, *goqu.Database) { // newTestStorage поднимает хранилище на пустом каталоге и накатывает схему —
db, err := sql.Open("sqlite3", ":memory:") // ровно тем же путём, каким это делает сервис при старте.
func newTestStorage(t *testing.T) core.App {
t.Helper()
app, err := pbrepo.New(t.TempDir())
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { db.Close() }) t.Cleanup(func() {
if err := app.ResetBootstrapState(); err != nil {
t.Logf("не удалось закрыть хранилище: %v", err)
}
})
// Каждому новому соединению с `:memory:` драйвер выдаёт свою базу, и return app
// второй потребитель пула не увидел бы накатанных миграций. Одно
// соединение снимает класс целиком.
db.SetMaxOpenConns(1)
gq := goqu.New("sqlite3", db)
err = goose.SetDialect("sqlite3")
require.NoError(t, err)
_, b, _, _ := runtime.Caller(0)
migpath, err := filepath.Abs(path.Join(b, "../../../../migrations"))
require.NoError(t, err)
goose.SetLogger(goose.NopLogger())
err = goose.Up(db, migpath)
require.NoError(t, err)
return db, gq
} }
func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv { func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
gin.SetMode(gin.TestMode) app := newTestStorage(t)
db, gq := setupTestDB(t) pbrepo.BindPanelRules(app)
storageDir := filepath.Join(t.TempDir(), "files") fileRepo := pbrepo.NewFileRepository(app)
require.NoError(t, os.MkdirAll(storageDir, 0o755)) jobRepo := pbrepo.NewTranscriptJobRepository(app)
fileRepo := sqlite.NewFileRepository(db, gq)
jobRepo := sqlite.NewTranscriptJobRepository(db, gq)
// Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на // Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на
// имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки // имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки
@@ -152,20 +126,6 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
journal := &journalBuffer{} journal := &journalBuffer{}
logger := slog.New(slog.NewTextHandler(journal, nil)) logger := slog.New(slog.NewTextHandler(journal, nil))
// Второй писатель журнала приёма — HTTP-транспорт: он пишет через стандартный
// `log` (расхождение записано в docs/conventions/logging.md). В бою `main.go`
// зовёт `slog.SetDefault`, и такая запись садится в `msg` строки `slog`;
// повторяем это здесь, чтобы оракул видел ту же цепочку, что и прод, а не
// свою. Без перехвата оракул был бы уже требования, которое накрывает все
// журнальные записи приёма.
//
// Подмена процессная, а не своя у случая: `t.Parallel()` в этом файле
// запрещён. При параллельных случаях вывод указывал бы на буфер соседа, и
// проверка запрета прошла бы, ничего не прочитав.
prevDefault := slog.Default()
slog.SetDefault(logger)
t.Cleanup(func() { slog.SetDefault(prevDefault) })
trsService := service.NewTranscribeService( trsService := service.NewTranscribeService(
jobRepo, jobRepo,
fileRepo, fileRepo,
@@ -173,27 +133,21 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
&stubConverter{}, &stubConverter{},
&recognizer.MemoryAudioRecognizer{}, &recognizer.MemoryAudioRecognizer{},
&TestTgSender{}, &TestTgSender{},
storageDir,
logger, logger,
) )
handler := NewTranscribeHandler(jobRepo, trsService) handler := NewTranscribeHandler(jobRepo, trsService, logger)
// Роутер собирается той же цепочкой, что и боевой (main.go): у приёма три // Роутер собирается тем же способом, что и боевой: маршруты вешает сам
// пишущих в журнал потока, и middleware — третий. Без него требование «ни // обработчик, и проверка судит ту же цепочку, что и прод.
// одна журнальная запись приёма» проверялось бы шире, чем оракул смотрит. r, err := apis.NewRouter(app)
router := gin.New() require.NoError(t, err)
router.Use(sloggin.New(logger)) handler.Register(r)
router.Use(gin.Recovery())
router.MaxMultipartMemory = 32 << 20 // 32 MiB
api := router.Group("/api") mux, err := r.BuildMux()
{ require.NoError(t, err)
api.POST("/audio", handler.CreateTranscribeJob)
api.GET("/status/:id", handler.GetTranscribeJobStatus)
}
return &testEnv{router: router, handler: handler, db: db, storageDir: storageDir, journal: journal} return &testEnv{mux: mux, handler: handler, app: app, journal: journal}
} }
// createMultipartRequest собирает запрос из имени и содержимого. Файла на диске // createMultipartRequest собирает запрос из имени и содержимого. Файла на диске
@@ -217,35 +171,67 @@ func createMultipartRequestWithField(t *testing.T, field, fileName string, conte
err = writer.Close() err = writer.Close()
require.NoError(t, err) require.NoError(t, err)
req, err := http.NewRequest("POST", "/api/audio", &buf) req := httptest.NewRequest("POST", "/api/audio", &buf)
require.NoError(t, err)
req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Content-Type", writer.FormDataContentType())
return req return req
} }
// storedFiles отдаёт содержимое каталога хранения. // storedFileNames отдаёт имена, под которыми файлы легли в хранилище.
func storedFiles(t *testing.T, env *testEnv) []string { func storedFileNames(t *testing.T, env *testEnv) []string {
files, err := filepath.Glob(filepath.Join(env.storageDir, "*")) records, err := env.app.FindAllRecords(pbrepo.FilesCollection)
require.NoError(t, err) require.NoError(t, err)
return files
var names []string
for _, record := range records {
names = append(names, record.GetStringSlice("file")...)
}
return names
}
// countFiles считает записи о файлах.
func countFiles(t *testing.T, env *testEnv) int {
records, err := env.app.FindAllRecords(pbrepo.FilesCollection)
require.NoError(t, err)
return len(records)
} }
// countJobs считает заведённые задачи расшифровки. // countJobs считает заведённые задачи расшифровки.
func countJobs(t *testing.T, env *testEnv) int { func countJobs(t *testing.T, env *testEnv) int {
var count int records, err := env.app.FindAllRecords(pbrepo.JobsCollection)
err := env.db.QueryRow("SELECT COUNT(*) FROM transcribe_jobs").Scan(&count)
require.NoError(t, err) require.NoError(t, err)
return count return len(records)
} }
// storedFileName отдаёт имя файла, записанное в учёте под данным идентификатором. // jobWithFile заводит задачу вместе с её записью: ссылка на файл обязательна
func storedFileName(t *testing.T, env *testEnv, fileID string) string { // схемой, потому что без неё задача не пройдёт ни одного шага.
var name string func jobWithFile(t *testing.T, env *testEnv) *entity.TranscribeJob {
err := env.db.QueryRow("SELECT file_name FROM files WHERE id = ?", fileID).Scan(&name) t.Helper()
repo := pbrepo.NewFileRepository(env.app)
work, err := repo.Stage(".mp3", strings.NewReader("запись"))
require.NoError(t, err) require.NoError(t, err)
return name defer func() { require.NoError(t, work.Close()) }()
file, err := repo.CreateLocal("sample.mp3", work)
require.NoError(t, err)
job := &entity.TranscribeJob{State: entity.StateCreated, Source: entity.SourceApi, FileID: &file.Id}
require.NoError(t, env.handler.jobRepo.Create(job))
return job
}
// storedContent читает содержимое файла из хранилища.
func storedContent(t *testing.T, env *testEnv, fileID string) []byte {
repo := pbrepo.NewFileRepository(env.app)
reader, err := repo.Open(fileID)
require.NoError(t, err)
defer reader.Close()
var buf bytes.Buffer
_, err = buf.ReadFrom(reader)
require.NoError(t, err)
return buf.Bytes()
} }
func TestCreateTranscribeJob_Success(t *testing.T) { func TestCreateTranscribeJob_Success(t *testing.T) {
@@ -255,7 +241,7 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
req := createMultipartRequest(t, "sample.m4a", content) req := createMultipartRequest(t, "sample.m4a", content)
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.router.ServeHTTP(w, req) env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
@@ -285,17 +271,9 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
require.NotNil(t, job.FileID) require.NotNil(t, job.FileID)
assert.NotEmpty(t, *job.FileID) assert.NotEmpty(t, *job.FileID)
// Содержимое лежит в каталоге хранения одним файлом и целиком. // Содержимое лежит в хранилище одним файлом и целиком.
files := storedFiles(t, env) require.Equal(t, 1, countFiles(t, env))
require.Len(t, files, 1) assert.Equal(t, content, storedContent(t, env, *job.FileID))
stored, err := os.ReadFile(files[0])
require.NoError(t, err)
assert.Equal(t, content, stored)
// Учёт указывает на этот самый файл, а не на какой-то другой: дальше по
// конвейеру путь берётся только из учёта, и разъезд убил бы задачу молча.
assert.Equal(t, filepath.Base(files[0]), storedFileName(t, env, *job.FileID))
} }
func TestCreateTranscribeJob_NoFile(t *testing.T) { func TestCreateTranscribeJob_NoFile(t *testing.T) {
@@ -308,9 +286,9 @@ func TestCreateTranscribeJob_NoFile(t *testing.T) {
{ {
name: "no body at all", name: "no body at all",
req: func(t *testing.T) *http.Request { req: func(t *testing.T) *http.Request {
req, err := http.NewRequest("POST", "/api/audio", nil) // Запрос строится так, как его видит сервер: у пришедшего по
require.NoError(t, err) // проводу тело не бывает пустым указателем.
return req return httptest.NewRequest("POST", "/api/audio", http.NoBody)
}, },
}, },
{ {
@@ -326,7 +304,7 @@ func TestCreateTranscribeJob_NoFile(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.router.ServeHTTP(w, tc.req(t)) env.mux.ServeHTTP(w, tc.req(t))
require.Equal(t, http.StatusBadRequest, w.Code) require.Equal(t, http.StatusBadRequest, w.Code)
@@ -335,7 +313,7 @@ func TestCreateTranscribeJob_NoFile(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "No audio file provided", response["error"]) assert.Equal(t, "No audio file provided", response["error"])
assert.Empty(t, storedFiles(t, env)) assert.Equal(t, 0, countFiles(t, env))
assert.Equal(t, 0, countJobs(t, env)) assert.Equal(t, 0, countJobs(t, env))
}) })
} }
@@ -349,7 +327,7 @@ func TestCreateTranscribeJob_EmptyFile(t *testing.T) {
req := createMultipartRequest(t, "empty.m4a", nil) req := createMultipartRequest(t, "empty.m4a", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.router.ServeHTTP(w, req) env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
@@ -396,28 +374,52 @@ func TestCreateTranscribeJob_DifferentFileExtensions(t *testing.T) {
req := createMultipartRequest(t, tc.fileName, []byte("запись")) req := createMultipartRequest(t, tc.fileName, []byte("запись"))
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.router.ServeHTTP(w, req) env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
files := storedFiles(t, env) names := storedFileNames(t, env)
require.Len(t, files, 1) require.Len(t, names, 1)
// Имя отправителя в хранилище не попадает: имя файла — свой // Имя отправителя в хранилище не попадает: имя файла — свой
// идентификатор, от отправителя взято только расширение. // идентификатор, от отправителя взято только расширение. Суффикс
assert.Equal(t, tc.expectExt, filepath.Ext(files[0])) // дописывает само хранилище, поэтому сверяем хвост, а не Ext.
assert.NotContains(t, filepath.Base(files[0]), tc.fileName) assert.True(t, strings.HasSuffix(names[0], tc.expectExt),
"имя в хранилище %q оканчивается на %q", names[0], tc.expectExt)
assert.NotContains(t, names[0], strings.TrimSuffix(tc.fileName, tc.expectExt))
}) })
} }
} }
// Имя, данное отправителем, в хранилище не попадает целиком — умолчание
// библиотеки, строящее имя файла из него, не применяется. Проверка отдельная от
// перебора расширений: там сверяется хвост, здесь — что основы имени нет.
func TestCreateTranscribeJob_SenderFileNameNotStored(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
req := createMultipartRequest(t, "секретное-слово.mp3", []byte("запись"))
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code)
names := storedFileNames(t, env)
require.Len(t, names, 1)
assert.NotContains(t, names[0], "секретное-слово",
"имя, данное отправителем, в хранилище не попадает")
assert.True(t, strings.HasSuffix(names[0], ".mp3"),
"расширение при этом сохраняется: %q", names[0])
}
func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) { func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) {
env := setupTestEnv(t, &stubMetaViewer{err: errors.New("не удалось прочитать запись")}) env := setupTestEnv(t, &stubMetaViewer{err: errors.New("не удалось прочитать запись")})
req := createMultipartRequest(t, "broken.m4a", []byte("не запись вовсе")) req := createMultipartRequest(t, "broken.m4a", []byte("не запись вовсе"))
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.router.ServeHTTP(w, req) env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code) require.Equal(t, http.StatusInternalServerError, w.Code)
@@ -439,14 +441,14 @@ func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) {
// пропустил бы. // пропустил бы.
const senderNameMarker = "SENDERNAMELEAKMARKER7Q2" const senderNameMarker = "SENDERNAMELEAKMARKER7Q2"
// Тексты, по которым проверки находят журнальные строки. Оба — записанный долг // Тексты, по которым проверки находят журнальные строки. Первый — записанный
// `docs/conventions/logging.md`: `msg` обязан стать короткой категорией, а // долг `docs/conventions/logging.md`: `msg` обязан стать короткой категорией.
// транспорту не положено логировать вовсе. Когда долг закроют, правка будет // Когда долг закроют, правка будет здесь и одна.
// здесь и одна, а смысл утверждений менять не придётся.
const ( const (
msgIntake = "Creating transcribe job" msgIntake = "Creating transcribe job"
msgTransportErr = "Err:" // Отказ пишет доменная граница — приём, — а не транспорт: конвенция просит
msgMiddleware = "Incoming request" // логировать ошибку один раз, и повторная запись транспорта снята.
msgIntakeErr = "Failed to get file info"
) )
func TestCreateTranscribeJob_SenderFileNameNotLogged(t *testing.T) { func TestCreateTranscribeJob_SenderFileNameNotLogged(t *testing.T) {
@@ -457,17 +459,16 @@ func TestCreateTranscribeJob_SenderFileNameNotLogged(t *testing.T) {
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("запись")) req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("запись"))
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.router.ServeHTTP(w, req) env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
journal := env.journal.String() journal := env.journal.String()
// Сперва — что поток middleware вообще перехвачен. Он третий писатель // Сперва — что журнал приёма вообще перехвачен. Без этого утверждения
// журнала приёма, и без этого утверждения снятие его из тестового роутера // пустой буфер сделал бы проверку запрета зелёной, ничего не прочитав.
// сузило бы оракул молча. require.Contains(t, journal, msgIntake,
require.Contains(t, journal, msgMiddleware, "строка приёма попадает в перехваченный журнал")
"строка middleware о запросе попадает в перехваченный журнал")
assert.NotContains(t, journal, senderNameMarker, assert.NotContains(t, journal, senderNameMarker,
"имя, данное отправителем, не пишется в журнал: инвариант приватности") "имя, данное отправителем, не пишется в журнал: инвариант приватности")
@@ -481,22 +482,45 @@ func TestCreateTranscribeJob_SenderFileNameNotLoggedOnFailure(t *testing.T) {
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("не запись вовсе")) req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("не запись вовсе"))
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.router.ServeHTTP(w, req) env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code) require.Equal(t, http.StatusInternalServerError, w.Code)
journal := env.journal.String() journal := env.journal.String()
// Сперва — что второй поток журнала вообще перехвачен. Без этого // Сперва — что журнал ветки отказа вообще перехвачен: обработчик пишет свою
// утверждения снятие `slog.SetDefault` из окружения оставило бы проверку // строку, и без неё оракул молча сузился бы вдвое.
// зелёной, а оракул критического инварианта молча сузился бы вдвое. require.Contains(t, journal, msgIntakeErr,
require.Contains(t, journal, msgTransportErr, "строка приёма об отказе попадает в перехваченный журнал")
"строка транспорта, идущая мимо slog, попадает в перехваченный журнал")
assert.NotContains(t, journal, senderNameMarker, assert.NotContains(t, journal, senderNameMarker,
"имя отправителя не пишется в журнал и на пути отказа") "имя отправителя не пишется в журнал и на пути отказа")
} }
// Имя, под которым файл лёг в хранилище, — это последняя часть ссылки
// `/api/files/...`, по которой запись скачивают. Попав в журнал, строка стала бы
// бессрочным ключом к чужому аудио, поэтому в журнал идёт имя, заданное
// сервисом, а суффикс хранилища — нет.
func TestCreateTranscribeJob_StorageFileNameNotLogged(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code)
names := storedFileNames(t, env)
require.Len(t, names, 1)
journal := env.journal.String()
require.Contains(t, journal, msgIntake, "журнал приёма перехвачен")
assert.NotContains(t, journal, names[0],
"имени файла в хранилище в журнале нет: по нему собирается ссылка на скачивание")
}
func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) { func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
@@ -504,7 +528,7 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
req := createMultipartRequest(t, "sample.mp3", content) req := createMultipartRequest(t, "sample.mp3", content)
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.router.ServeHTTP(w, req) env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
@@ -569,7 +593,7 @@ func TestCreateTranscribeJob_MetricLabelCarriesNoSenderName(t *testing.T) {
req := createMultipartRequest(t, "sample."+senderNameMarker, []byte("запись")) req := createMultipartRequest(t, "sample."+senderNameMarker, []byte("запись"))
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.router.ServeHTTP(w, req) env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code) require.Equal(t, http.StatusCreated, w.Code)
@@ -582,41 +606,30 @@ func TestCreateTranscribeJob_MetricLabelCarriesNoSenderName(t *testing.T) {
assert.Contains(t, values, "other", assert.Contains(t, values, "other",
"незнакомое расширение приведено к общему значению") "незнакомое расширение приведено к общему значению")
// А на диске расширение остаётся пришедшим: раскладка каталога записей // А в хранилище расширение остаётся пришедшим: приведение сюда не
// объявлена необратимой, и приведение сюда не распространяется. // распространяется.
files := storedFiles(t, env) names := storedFileNames(t, env)
require.Len(t, files, 1) require.Len(t, names, 1)
assert.Equal(t, "."+senderNameMarker, filepath.Ext(files[0])) assert.True(t, strings.HasSuffix(names[0], "."+senderNameMarker),
"имя в хранилище сохраняет пришедшее расширение: %q", names[0])
} }
func TestGetTranscribeJobStatus_Success(t *testing.T) { func TestGetTranscribeJobStatus_Success(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
job := &entity.TranscribeJob{ job := jobWithFile(t, env)
Id: "test-job-id",
State: entity.StateCreated,
Source: entity.SourceApi,
FileID: nil,
IsError: false,
CreatedAt: time.Now(),
}
err := env.handler.jobRepo.Create(job) req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)
require.NoError(t, err)
req, err := http.NewRequest("GET", "/api/status/test-job-id", nil)
require.NoError(t, err)
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.router.ServeHTTP(w, req) env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
var response GetTranscribeJobResponse var response GetTranscribeJobResponse
err = json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
require.NoError(t, err)
assert.Equal(t, "test-job-id", response.JobID) assert.Equal(t, job.Id, response.JobID)
assert.Equal(t, entity.StateCreated, response.State) assert.Equal(t, entity.StateCreated, response.State)
assert.NotZero(t, response.CreatedAt) assert.NotZero(t, response.CreatedAt)
} }
@@ -624,21 +637,12 @@ func TestGetTranscribeJobStatus_Success(t *testing.T) {
func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) { func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
job := &entity.TranscribeJob{ job := jobWithFile(t, env)
Id: "job-without-text",
State: entity.StateCreated,
Source: entity.SourceApi,
CreatedAt: time.Now(),
}
err := env.handler.jobRepo.Create(job) req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)
require.NoError(t, err)
req, err := http.NewRequest("GET", "/api/status/job-without-text", nil)
require.NoError(t, err)
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.router.ServeHTTP(w, req) env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
@@ -646,8 +650,7 @@ func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) {
// читается клиентом как «расшифровка пуста», и разобранная структура // читается клиентом как «расшифровка пуста», и разобранная структура
// эти два случая не различает. // эти два случая не различает.
var raw map[string]json.RawMessage var raw map[string]json.RawMessage
err = json.Unmarshal(w.Body.Bytes(), &raw) require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
require.NoError(t, err)
assert.Contains(t, raw, "job_id") assert.Contains(t, raw, "job_id")
assert.Contains(t, raw, "status") assert.Contains(t, raw, "status")
@@ -658,17 +661,15 @@ func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) {
func TestGetTranscribeJobStatus_NotFound(t *testing.T) { func TestGetTranscribeJobStatus_NotFound(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
req, err := http.NewRequest("GET", "/api/status/non-existent-id", nil) req := httptest.NewRequest("GET", "/api/status/non-existent-id", http.NoBody)
require.NoError(t, err)
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.router.ServeHTTP(w, req) env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusNotFound, w.Code) require.Equal(t, http.StatusNotFound, w.Code)
var response map[string]string var response map[string]string
err = json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
require.NoError(t, err)
assert.Equal(t, "Job not found", response["error"]) assert.Equal(t, "Job not found", response["error"])
} }
+21 -13
View File
@@ -4,25 +4,33 @@ import (
"time" "time"
) )
// Где лежит копия файла. Поле названо `location`, а не `storage`: последним
// словом зовут само хранилище, и третий смысл у одного слова развёл бы по
// разным вещам запись о файле и хранилище, в котором она лежит.
const ( const (
StorageLocal = "local" LocationLocal = "local"
StorageS3 = "s3" LocationS3 = "s3"
) )
// MaxRecordSize — потолок размера одного файла записи. Выведен из расчётного
// потолка записи в шесть часов с запасом на видео, а не из замера.
//
// Число нужно назвать **явно** в двух местах сразу: у поля файла в хранилище
// нулевой потолок значит не «без предела», а умолчание библиотеки в 5 МиБ, а у
// тела запроса приёма умолчание роутера отсекало бы запись раньше, чем она
// дойдёт до обработчика — без строки в журнале приёма.
const MaxRecordSize int64 = 8 << 30 // 8 ГиБ
// File — одна физическая копия: исходник, результат конвертации и копия во
// внешнем хранилище — три разные записи.
type File struct { type File struct {
Id string Id string
Storage string Location string
// FileName — имя, под которым файл лежит: у местной копии это имя, заданное
// сервисом, у внешней — ключ объекта. Своего суффикса хранилище к заданному
// имени не дописывает: суффикс появляется только у имён, которые оно строит
// само из имени отправителя, а это умолчание не применяется.
FileName string FileName string
Size int64 Size int64
CreatedAt time.Time CreatedAt time.Time
} }
func (f *File) CopyWithStorage(newId, storage string) *File {
return &File{
Id: newId,
Storage: storage,
FileName: f.FileName,
Size: f.Size,
CreatedAt: time.Now(),
}
}
+30 -2
View File
@@ -9,11 +9,11 @@ type TranscribeJob struct {
State string State string
Source string Source string
FileID *string FileID *string
IsError bool
ErrorText *string ErrorText *string
AcquisitionID *string AcquisitionID *string
AcquireTime *time.Time AcquireTime *time.Time
DelayTime *time.Time DelayTime *time.Time
Attempts int // Число попыток: растёт при захвате, обнуляется на шаге без отказа
RecognitionOpID *string // ID операции распознавания в Yandex Cloud RecognitionOpID *string // ID операции распознавания в Yandex Cloud
TranscriptionText *string // Результат распознавания TranscriptionText *string // Результат распознавания
TgChatId *int64 // Telegram: в какой чат отправить результат распознавания TgChatId *int64 // Telegram: в какой чат отправить результат распознавания
@@ -28,6 +28,11 @@ const (
StateTranscribe = "transcribe" StateTranscribe = "transcribe"
StateDone = "done" StateDone = "done"
StateFailed = "failed" StateFailed = "failed"
// StateDead — задача, которую мы повторяли и перестали. От `failed` она
// отличается тем, чей это приговор: в `failed` задачу переводит шаг,
// рассудивший об этой записи окончательно, а сюда она уходит без такого
// суждения. Ни один шаг конвейера в неё не переводит сам.
StateDead = "dead"
) )
const ( const (
@@ -43,6 +48,10 @@ func (j *TranscribeJob) MoveToState(state string) {
j.DelayTime = nil j.DelayTime = nil
j.AcquisitionID = nil j.AcquisitionID = nil
j.AcquireTime = nil j.AcquireTime = nil
// Шаг, дошедший до перехода, завершился без отказа, а попытки считают
// именно отказавшие: иначе задача, прошедшая конвейер целиком, накопила бы
// их поштучно и умерла бы здоровой.
j.Attempts = 0
j.UpdatedAt = time.Now() j.UpdatedAt = time.Now()
} }
@@ -59,6 +68,25 @@ func (j *TranscribeJob) Done(transcriptionText string) {
func (j *TranscribeJob) Fail(errText string) { func (j *TranscribeJob) Fail(errText string) {
j.MoveToState(StateFailed) j.MoveToState(StateFailed)
j.IsError = true j.ErrorText = &errText
}
// RetryAfter освобождает отказавшую задачу для повтора: захват снимается,
// пауза ставится, а число попыток сохраняется — по нему растёт пауза и
// наступает предел.
func (j *TranscribeJob) RetryAfter(delay time.Time) {
j.AcquisitionID = nil
j.AcquireTime = nil
j.DelayTime = &delay
j.UpdatedAt = time.Now()
}
// Die переводит задачу, исчерпавшую попытки, в состояние «мертва». Число
// попыток при этом сохраняется: по нему видно, сколько раз мы пробовали, а
// возвращает задачу в работу владелец правкой состояния.
func (j *TranscribeJob) Die(errText string) {
attempts := j.Attempts
j.MoveToState(StateDead)
j.Attempts = attempts
j.ErrorText = &errText j.ErrorText = &errText
} }
+4 -4
View File
@@ -25,7 +25,7 @@ type stubJobRepo struct {
} }
func (r *stubJobRepo) Create(*entity.TranscribeJob) error { return nil } func (r *stubJobRepo) Create(*entity.TranscribeJob) error { return nil }
func (r *stubJobRepo) Save(*entity.TranscribeJob) error { return nil } func (r *stubJobRepo) Save(*entity.TranscribeJob, string) error { return nil }
func (r *stubJobRepo) GetByID(string) (*entity.TranscribeJob, error) { func (r *stubJobRepo) GetByID(string) (*entity.TranscribeJob, error) {
return nil, errors.New("не зовётся этими проверками") return nil, errors.New("не зовётся этими проверками")
@@ -37,7 +37,7 @@ func (r *stubJobRepo) FindAndAcquire(string, string, time.Time) (*entity.Transcr
func serviceWithRepo(repo contract.TranscriptJobRepository) *TranscribeService { func serviceWithRepo(repo contract.TranscriptJobRepository) *TranscribeService {
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
return NewTranscribeService(repo, nil, nil, nil, nil, nil, "", logger) return NewTranscribeService(repo, nil, nil, nil, nil, nil, logger)
} }
// Репозиторий вправе добавить своему отказу пояснение — соседние ветки того же // Репозиторий вправе добавить своему отказу пояснение — соседние ветки того же
@@ -50,7 +50,7 @@ func TestFindJobTranslatesWrappedNotFoundToNoop(t *testing.T) {
&contract.JobNotFoundError{State: "created", Message: "appropriate job not found"}), &contract.JobNotFoundError{State: "created", Message: "appropriate job not found"}),
}) })
_, err := svc.findJob("created", time.Minute) _, _, err := svc.findJob("created", time.Minute)
var noop *contract.NoopJobError var noop *contract.NoopJobError
if !errors.As(err, &noop) { if !errors.As(err, &noop) {
@@ -66,7 +66,7 @@ func TestFindJobTranslatesWrappedNotFoundToNoop(t *testing.T) {
func TestFindJobKeepsRealFailure(t *testing.T) { func TestFindJobKeepsRealFailure(t *testing.T) {
svc := serviceWithRepo(&stubJobRepo{err: errors.New("database is gone")}) svc := serviceWithRepo(&stubJobRepo{err: errors.New("database is gone")})
_, err := svc.findJob("created", time.Minute) _, _, err := svc.findJob("created", time.Minute)
var noop *contract.NoopJobError var noop *contract.NoopJobError
if errors.As(err, &noop) { if errors.As(err, &noop) {
+362
View File
@@ -0,0 +1,362 @@
package service
import (
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"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/recognizer"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Проверки конвейера идут против настоящего хранилища: захват, число попыток и
// переход в «мертва» держатся на запросе, и подставной репозиторий проверял бы
// собственную заглушку, а не то, что делает база.
// failingConverter отказывает на каждой попытке.
type failingConverter struct{}
func (c *failingConverter) Convert(string, string) error {
return errors.New("конвертация не удалась")
}
type okMetaViewer struct{}
func (m *okMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
return &contract.AudioInfo{Seconds: 1}, nil
}
type failingMetaViewer struct{}
func (m *failingMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
return nil, errors.New("запись не читается")
}
// recordingSender запоминает, что и куда отправлено.
type recordingSender struct {
messages []string
}
func (s *recordingSender) Send(text string, chatId int64, replyMsgId *int) error {
s.messages = append(s.messages, text)
return nil
}
type pipelineEnv struct {
app core.App
service *TranscribeService
jobRepo *pbrepo.TranscriptJobRepository
fileRepo *pbrepo.FileRepository
sender *recordingSender
}
func newPipelineEnv(t *testing.T, metaviewer contract.AudioMetaViewer, converter contract.AudioFileConverter) *pipelineEnv {
t.Helper()
app, err := pbrepo.New(t.TempDir())
require.NoError(t, err)
t.Cleanup(func() {
if err := app.ResetBootstrapState(); err != nil {
t.Logf("не удалось закрыть хранилище: %v", err)
}
})
// Правила панели вешаются и здесь: конфигурация под проверкой обязана
// совпадать с боевой, иначе утверждения говорят про прод то, чего в проде
// нет.
pbrepo.BindPanelRules(app)
jobRepo := pbrepo.NewTranscriptJobRepository(app)
fileRepo := pbrepo.NewFileRepository(app)
sender := &recordingSender{}
svc := NewTranscribeService(
jobRepo,
fileRepo,
metaviewer,
converter,
&recognizer.MemoryAudioRecognizer{},
sender,
slog.New(slog.NewTextHandler(io.Discard, nil)),
)
return &pipelineEnv{app: app, service: svc, jobRepo: jobRepo, fileRepo: fileRepo, sender: sender}
}
// newTelegramJob заводит задачу с записью — так, как её завёл бы приём.
func newTelegramJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob {
t.Helper()
chatId := int64(100)
job, err := env.service.CreateJobFromTelegram(strings.NewReader("запись"), "voice.ogg", chatId, 1)
require.NoError(t, err)
return job
}
// clearDelay снимает паузу, чтобы следующий прогон взял задачу сразу: проверка
// судит счётчик попыток, а не то, умеет ли она ждать.
func clearDelay(t *testing.T, env *pipelineEnv, jobID string) {
t.Helper()
record, err := env.app.FindRecordById(pbrepo.JobsCollection, jobID)
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) {
t.Helper()
record, err := env.app.FindRecordById(pbrepo.JobsCollection, jobID)
require.NoError(t, err)
record.Set("acquire_time", types.NowDateTime().Add(-24*time.Hour))
require.NoError(t, env.app.Save(record))
}
// Задача, падающая на каждой попытке, уходит в «мертва»: из выборки исчезает,
// видна отбором по состоянию, а отправитель узнаёт о неудаче. Инвариант
// «Принятая запись не теряется молча» допускает два исхода, и молчаливая смерть
// не подходит ни под один.
func TestJobDiesAfterAttemptLimit(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job := newTelegramJob(t, env)
// Отказ конвертации переводит задачу в `failed` сразу, поэтому предел
// попыток проверяем на шаге, который отказывает *не* приговором: подменяем
// его отказом источника метаданных внутри самого шага конвертации нельзя, и
// вместо этого гоняем захват без выполнения шага — так же, как это выглядит
// при гибели процесса.
for i := 0; i < maxAttempts; i++ {
_, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
require.NoError(t, err)
}
rotAcquisition(t, env, job.Id)
// Следующий захват видит перебор и хоронит задачу.
err := env.service.FindAndRunConversionJob()
var noop *contract.NoopJobError
require.ErrorAs(t, err, &noop, "мёртвая задача шагу не отдаётся")
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateDead, after.State, "задача видна отбором по состоянию")
assert.Greater(t, after.Attempts, maxAttempts, "число попыток сохранено")
require.Len(t, env.sender.messages, 1, "отправитель узнал о неудаче")
assert.Contains(t, env.sender.messages[0], "попытки исчерпаны")
// И из выборки она исчезла.
_, err = env.jobRepo.FindAndAcquire(entity.StateCreated, "next", time.Now().Add(time.Hour))
var missing *contract.JobNotFoundError
assert.ErrorAs(t, err, &missing)
}
// Мёртвая задача возвращается в работу правкой состояния.
func TestDeadJobReturnsAfterStateEdit(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job := newTelegramJob(t, env)
for i := 0; i < maxAttempts; i++ {
_, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
require.NoError(t, err)
}
require.Error(t, env.service.FindAndRunConversionJob())
record, err := env.app.FindRecordById(pbrepo.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{})
job := newTelegramJob(t, env)
// Ссылку переставляем на запись без содержимого: шаг отказывает на получении
// рабочей копии — то есть отказом, а не приговором записи.
empty, err := env.fileRepo.CreateRemote("object-key", 1)
require.NoError(t, err)
record, err := env.app.FindRecordById(pbrepo.JobsCollection, job.Id)
require.NoError(t, err)
record.Set("file", empty.Id)
require.NoError(t, env.app.Save(record))
// Первый отказ.
require.Error(t, env.service.FindAndRunConversionJob())
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
require.Nil(t, after.AcquisitionID, "захват снят: задача пригодна к повтору")
require.NotNil(t, after.DelayTime, "пауза поставлена")
firstDelay := time.Until(*after.DelayTime)
// Второй отказ — с той же задачи, пауза снята вручную.
clearDelay(t, env, job.Id)
require.Error(t, env.service.FindAndRunConversionJob())
after, err = env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
require.NotNil(t, after.DelayTime)
secondDelay := time.Until(*after.DelayTime)
assert.Greater(t, secondDelay, firstDelay, "вторая пауза длиннее первой")
}
// Пауза растёт с числом попыток и упирается в потолок.
func TestRetryDelayGrowsAndCaps(t *testing.T) {
assert.Equal(t, retryDelayBase, retryDelay(1))
assert.Equal(t, 2*retryDelayBase, retryDelay(2))
assert.Greater(t, retryDelay(3), retryDelay(2))
assert.Equal(t, retryDelayCap, retryDelay(100), "пауза упирается в потолок")
assert.Equal(t, retryDelayBase, retryDelay(0), "нулевая попытка не даёт нулевой паузы")
}
// Рабочая копия убирается на любом исходе, включая отказ. Забытая копия — это
// шестичасовая запись во временном каталоге, и узнать о ней неоткуда.
func TestWorkFileRemovedAfterIntakeFailure(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("TMPDIR", tempDir)
env := newPipelineEnv(t, &failingMetaViewer{}, &failingConverter{})
_, err := env.service.CreateJobFromApi(strings.NewReader("запись"), "sample.mp3")
require.Error(t, err, "отказ источника метаданных роняет приём")
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
require.NoError(t, err)
assert.Empty(t, leftovers, "рабочей копии после отказа не остаётся")
}
// Успешный приём тоже за собой убирает: копия нужна была только на время
// укладки в хранилище.
func TestWorkFileRemovedAfterSuccessfulIntake(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("TMPDIR", tempDir)
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
_, err := env.service.CreateJobFromApi(strings.NewReader("запись"), "sample.mp3")
require.NoError(t, err)
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
require.NoError(t, err)
assert.Empty(t, leftovers, "рабочей копии после успеха не остаётся")
}
// Задача не остаётся ссылающейся на файл, которого нет: ссылка переставляется
// только после того, как запись о новом файле существует.
func TestJobNeverPointsToMissingFile(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job := newTelegramJob(t, env)
// Конвертация отказывает — задача уходит в `failed`, но ссылка остаётся на
// исходную запись, а не на несозданный результат.
require.NoError(t, env.service.FindAndRunConversionJob())
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateFailed, after.State)
require.NotNil(t, after.FileID)
file, err := env.fileRepo.GetByID(*after.FileID)
require.NoError(t, err, "ссылка задачи ведёт на существующую запись о файле")
assert.NotEmpty(t, file.FileName)
}
// Содержимое доезжает до хранилища целиком и читается обратно тем же.
func TestStoredContentSurvivesRoundTrip(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
content := strings.Repeat("запись ", 1000)
job, err := env.service.CreateJobFromApi(strings.NewReader(content), "sample.mp3")
require.NoError(t, err)
require.NotNil(t, job.FileID)
reader, err := env.fileRepo.Open(*job.FileID)
require.NoError(t, err)
defer reader.Close()
stored, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, content, string(stored))
// И длина в учёте совпадает с длиной принятого.
file, err := env.fileRepo.GetByID(*job.FileID)
require.NoError(t, err)
assert.Equal(t, int64(len(content)), file.Size)
}
// Рабочая копия хранимого файла отдаётся именем на диске — так её получают
// шаги, отдающие файл внешней программе.
func TestLocalizeGivesReadableCopy(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job, err := env.service.CreateJobFromApi(strings.NewReader("содержимое"), "sample.mp3")
require.NoError(t, err)
require.NotNil(t, job.FileID)
work, err := env.fileRepo.Localize(*job.FileID)
require.NoError(t, err)
content, err := os.ReadFile(work.Path())
require.NoError(t, err)
assert.Equal(t, "содержимое", string(content))
require.NoError(t, work.Close())
_, err = os.Stat(work.Path())
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())
leftovers, err = filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
require.NoError(t, err)
assert.Empty(t, leftovers, "ни исходной копии, ни копии под результат не осталось")
}
+267
View File
@@ -0,0 +1,267 @@
package service
import (
"errors"
"io"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"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
}
func (r *scriptedRecognizer) Recognize(file io.Reader, fileName string) (string, error) {
r.recognizeCalls++
r.lastObjectKey = fileName
if r.recognizeErr != nil {
return "", r.recognizeErr
}
// Содержимое обязано быть читаемым: шаг отдаёт его наружу потоком.
if _, err := io.Copy(io.Discard, file); err != nil {
return "", err
}
return "operation-id", nil
}
func (r *scriptedRecognizer) GetRecognitionText(string) (string, error) {
return r.text, nil
}
func (r *scriptedRecognizer) CheckRecognitionStatus(string) (*entity.RecognitionResult, error) {
return r.result, nil
}
// convertedJob доводит задачу до состояния, с которого работает шаг
// распознавания: запись принята и сконвертирована.
func convertedJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob {
t.Helper()
job := newTelegramJob(t, env)
acquired, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "setup", time.Now().Add(-time.Hour))
require.NoError(t, err)
acquired.MoveToState(entity.StateConverted)
require.NoError(t, env.jobRepo.Save(acquired, "setup"))
return job
}
// 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())
assert.Equal(t, 1, rec.recognizeCalls, "содержимое отдано распознавателю")
assert.NotEmpty(t, rec.lastObjectKey, "ключ объекта назван")
after, err := env.jobRepo.GetByID(job.Id)
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.NotNil(t, after.FileID)
copyRecord, err := env.fileRepo.GetByID(*after.FileID)
require.NoError(t, err)
assert.Equal(t, entity.LocationS3, copyRecord.Location)
}
// Отказ распознавателя не двигает задачу: она остаётся пригодной к повтору.
func TestTranscribeJobKeepsJobRetryableOnRecognizerFailure(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job := convertedJob(t, env)
rec := &scriptedRecognizer{recognizeErr: errors.New("распознаватель недоступен")}
svc := withRecognizer(env, rec)
require.Error(t, svc.FindAndRunTranscribeJob())
after, err := env.jobRepo.GetByID(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, "отправителю про повторимый отказ не пишут")
}
// transcribingJob доводит задачу до состояния ожидания операции.
func transcribingJob(t *testing.T, env *pipelineEnv, rec contract.AudioRecognizer) *entity.TranscribeJob {
t.Helper()
job := convertedJob(t, env)
require.NoError(t, withRecognizer(env, rec).FindAndRunTranscribeJob())
clearDelay(t, env, job.Id)
return job
}
// Ожидание чужой операции попытку не тратит и опрос не учащает: шаг отработал
// без отказа, и задержка у него своя, числом.
func TestCheckJobWaitsWithoutSpendingAttempts(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
svc := withRecognizer(env, rec)
for i := 0; i < 3; i++ {
require.NoError(t, svc.FindAndRunTranscribeCheckJob())
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateTranscribe, after.State)
assert.Equal(t, 0, after.Attempts, "ожидание операции попытку не тратит")
require.NotNil(t, after.DelayTime)
assert.InDelta(t, nextCheckDelay.Seconds(), time.Until(*after.DelayTime).Seconds(), 2,
"задержка опроса не выродилась в наименьшую паузу повтора")
clearDelay(t, env, job.Id)
}
}
// Отказ операции распознавания — приговор записи: задача уходит в `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())
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateFailed, after.State)
require.Len(t, env.sender.messages, 1, "отправитель узнал об отказе")
assert.Contains(t, env.sender.messages[0], "сбой при распознавании файла")
assert.NotContains(t, env.sender.messages[0], "операция отклонена",
"машинная причина отправителю не идёт")
}
// Готовая операция завершает задачу и отдаёт текст отправителю ровно один раз.
func 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())
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateDone, after.State)
require.NotNil(t, after.TranscriptionText)
assert.Equal(t, "расшифровка записи", *after.TranscriptionText)
require.Len(t, env.sender.messages, 1, "отправитель получил ровно один ответ")
assert.Equal(t, "расшифровка записи", env.sender.messages[0])
// И задача из выборки исчезла: второй ответ отправителю неоткуда взяться.
_, err = env.jobRepo.FindAndAcquire(entity.StateTranscribe, "next", time.Now().Add(-time.Hour))
var missing *contract.JobNotFoundError
assert.ErrorAs(t, err, &missing)
}
// Пустая расшифровка — не отказ: задача завершается, а отправителю уходит
// объяснение вместо пустого сообщения.
func TestCheckJobCompletesEmptyTextWithExplanation(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
job := transcribingJob(t, env, rec)
rec.result = entity.NewCompletedResult()
rec.text = ""
svc := withRecognizer(env, rec)
require.NoError(t, svc.FindAndRunTranscribeCheckJob())
after, err := env.jobRepo.GetByID(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(pocketbase.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(acquired, "mine")
var lost *contract.LostAcquisitionError
require.ErrorAs(t, err, &lost)
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateTranscribe, after.State, "результат не записан")
assert.Empty(t, env.sender.messages, "отправителю ничего не отправлено")
}
+240 -165
View File
@@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"os" "math"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
@@ -18,6 +18,29 @@ import (
const ( const (
defaultAudioExt = "audio" 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
) )
type TranscribeService struct { type TranscribeService struct {
@@ -27,7 +50,6 @@ type TranscribeService struct {
converter contract.AudioFileConverter converter contract.AudioFileConverter
recognizer contract.AudioRecognizer recognizer contract.AudioRecognizer
tgSender contract.TelegramMessageSender tgSender contract.TelegramMessageSender
storagePath string
logger *slog.Logger logger *slog.Logger
} }
@@ -38,7 +60,6 @@ func NewTranscribeService(
converter contract.AudioFileConverter, converter contract.AudioFileConverter,
recognizer contract.AudioRecognizer, recognizer contract.AudioRecognizer,
tgSender contract.TelegramMessageSender, tgSender contract.TelegramMessageSender,
storagePath string,
logger *slog.Logger, logger *slog.Logger,
) *TranscribeService { ) *TranscribeService {
return &TranscribeService{ return &TranscribeService{
@@ -48,162 +69,163 @@ func NewTranscribeService(
converter: converter, converter: converter,
recognizer: recognizer, recognizer: recognizer,
tgSender: tgSender, tgSender: tgSender,
storagePath: storagePath,
logger: logger, logger: logger,
} }
} }
func (s *TranscribeService) CreateJobFromTelegram(file io.Reader, fileName string, chatId int64, replyMsgId int) (*entity.TranscribeJob, error) { func (s *TranscribeService) CreateJobFromTelegram(file io.Reader, fileName string, chatId int64, replyMsgId int) (*entity.TranscribeJob, error) {
jobId := uuid.NewString()
now := time.Now()
job := &entity.TranscribeJob{ job := &entity.TranscribeJob{
Id: jobId,
State: entity.StateCreated, State: entity.StateCreated,
Source: entity.SourceTelegram, Source: entity.SourceTelegram,
TgChatId: &chatId, TgChatId: &chatId,
TgReplyMessageId: &replyMsgId, TgReplyMessageId: &replyMsgId,
IsError: false,
CreatedAt: now,
UpdatedAt: now,
} }
return s.createTranscribeJob(job, file, fileName) return s.createTranscribeJob(job, file, fileName)
} }
func (s *TranscribeService) CreateJobFromApi(file io.Reader, fileName string) (*entity.TranscribeJob, error) { func (s *TranscribeService) CreateJobFromApi(file io.Reader, fileName string) (*entity.TranscribeJob, error) {
jobId := uuid.NewString()
now := time.Now()
job := &entity.TranscribeJob{ job := &entity.TranscribeJob{
Id: jobId,
State: entity.StateCreated, State: entity.StateCreated,
Source: entity.SourceApi, Source: entity.SourceApi,
IsError: false,
CreatedAt: now,
UpdatedAt: now,
} }
return s.createTranscribeJob(job, file, fileName) return s.createTranscribeJob(job, file, fileName)
} }
func (s *TranscribeService) createTranscribeJob(job *entity.TranscribeJob, file io.Reader, fileName string) (*entity.TranscribeJob, error) { func (s *TranscribeService) createTranscribeJob(job *entity.TranscribeJob, file io.Reader, fileName string) (*entity.TranscribeJob, error) {
// Генерируем UUID для файла
fileId := uuid.NewString()
// Определяем расширение файла // Определяем расширение файла
ext := filepath.Ext(fileName) ext := filepath.Ext(fileName)
if ext == "" { if ext == "" {
ext = fmt.Sprintf(".%s", defaultAudioExt) // fallback если расширение не определено ext = fmt.Sprintf(".%s", defaultAudioExt) // fallback если расширение не определено
} }
// Создаем путь для сохранения файла // Собственное имя записи: идентификатор с расширением. Имя, данное
// отправителем, в хранилище не попадает — от него взято только расширение.
fileId := uuid.NewString()
storageFileName := fmt.Sprintf("%s%s", fileId, ext) storageFileName := fmt.Sprintf("%s%s", fileId, ext)
storageFilePath := filepath.Join(s.storagePath, storageFileName)
// Имя, данное отправителем, в журнал не идёт: инвариант приватности. // Содержимое ложится в рабочую копию потоком: в память запись целиком не
// Расширение из него уже стоит в собственном имени файла на диске. // читается, расчётный потолок — шесть часов.
s.logger.Info("Creating transcribe job", work, err := s.fileRepo.Stage(ext, file)
"file_id", fileId,
"storage_path", storageFilePath)
// Создаем файл на диске
dst, err := os.Create(storageFilePath)
if err != nil { if err != nil {
s.logger.Error("Failed to create file", "error", err, "path", storageFilePath) s.logger.Error("Failed to stage uploaded file", "error", err)
return nil, err return nil, err
} }
defer dst.Close() defer s.closeWork(work)
// Копируем содержимое загруженного файла // В журнал идёт расширение, и только оно. Имя, данное отправителем, не
size, err := io.Copy(dst, file) // пишется по инварианту приватности; имя, под которым файл ложится в
// хранилище, — потому что оно последняя часть ссылки на скачивание, и
// строка журнала вместе с идентификатором записи собрала бы её целиком.
s.logger.Info("Creating transcribe job", "file_ext", ext)
info, err := s.metaviewer.GetInfo(work.Path())
if err != nil { if err != nil {
s.logger.Error("Failed to copy file content", "error", err) s.logger.Error("Failed to get file info", "error", err, "file_ext", ext)
return nil, err return nil, err
} }
if err := dst.Close(); err != nil { size, err := work.Size()
s.logger.Error("Failed to close file", "error", err) if err != nil {
s.logger.Error("Failed to measure uploaded file", "error", err)
return nil, err return nil, err
} }
info, err := s.metaviewer.GetInfo(storageFilePath) fileRecord, err := s.fileRepo.CreateLocal(storageFileName, work)
if err != nil { if err != nil {
s.logger.Error("Failed to get file info", "error", err, "path", storageFilePath) s.logger.Error("Failed to create file record", "error", err, "file_ext", ext)
return nil, err return nil, err
} }
s.logger.Info("File uploaded successfully", s.logger.Info("File uploaded successfully",
"file_id", fileId, "file_id", fileRecord.Id,
"size", size, "size", size,
"duration_seconds", info.Seconds) "duration_seconds", info.Seconds)
metrics.InputFileDurationHistogram.WithLabelValues().Observe(float64(info.Seconds)) metrics.InputFileDurationHistogram.WithLabelValues().Observe(float64(info.Seconds))
metrics.ObserveInputFileSize(ext, size) metrics.ObserveInputFileSize(ext, size)
// Создаем запись в таблице files job.FileID = &fileRecord.Id
fileRecord := &entity.File{
Id: fileId,
Storage: entity.StorageLocal,
FileName: storageFileName,
Size: size,
CreatedAt: time.Now(),
}
if err := s.fileRepo.Create(fileRecord); err != nil {
// Удаляем файл если не удалось создать запись в БД
os.Remove(storageFilePath)
s.logger.Error("Failed to create file record", "error", err, "file_id", fileId)
return nil, err
}
job.FileID = &fileId
if err := s.jobRepo.Create(job); err != nil { if err := s.jobRepo.Create(job); err != nil {
s.logger.Error("Failed to create job record", "error", err, "job_id", job.Id) s.logger.Error("Failed to create job record", "error", err, "file_id", fileRecord.Id)
return nil, err return nil, err
} }
s.logger.Info("Transcribe job created successfully", "job_id", job.Id, "file_id", fileId) s.logger.Info("Transcribe job created successfully", "job_id", job.Id, "file_id", fileRecord.Id)
return job, nil return job, nil
} }
func (s *TranscribeService) FindAndRunConversionJob() error { func (s *TranscribeService) FindAndRunConversionJob() error {
job, err := s.findJob(entity.StateCreated, time.Hour) return s.runStep(entity.StateCreated, conversionAcquireTimeout, s.convertJob)
}
func (s *TranscribeService) FindAndRunTranscribeJob() error {
return s.runStep(entity.StateConverted, transcribeAcquireTimeout, s.transcribeJob)
}
func (s *TranscribeService) FindAndRunTranscribeCheckJob() error {
return s.runStep(entity.StateTranscribe, checkAcquireTimeout, s.checkTranscribeJob)
}
// runStep забирает задачу и отдаёт её шагу. Отказ шага не оставляет задачу
// захваченной до конца срока: захват снимается, и задача ждёт нарастающую паузу
// — иначе повтор наступал бы через восемь часов, а не через секунду.
func (s *TranscribeService) runStep(state string, expiration time.Duration, step func(job *entity.TranscribeJob, holder string) error) error {
job, holder, err := s.findJob(state, expiration)
if err != nil { if err != nil {
return err return err
} }
if err := step(job, holder); err != nil {
s.scheduleRetry(job, holder, err)
return err
}
return nil
}
func (s *TranscribeService) convertJob(job *entity.TranscribeJob, holder string) error {
s.logger.Info("Starting conversion job", "job_id", job.Id) 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) srcFile, err := s.fileRepo.GetByID(*job.FileID)
if err != nil { if err != nil {
s.logger.Error("Failed to get source file", "error", err, "file_id", *job.FileID) s.logger.Error("Failed to get source file", "error", err, "file_id", *job.FileID)
return err return err
} }
srcFilePath := filepath.Join(s.storagePath, srcFile.FileName)
destFileId := uuid.NewString()
destFileName := fmt.Sprintf("%s%s", destFileId, ".ogg")
destFilePath := filepath.Join(s.storagePath, destFileName)
// Получаем расширение исходного файла для метрики // Получаем расширение исходного файла для метрики
srcExt := strings.TrimPrefix(filepath.Ext(srcFile.FileName), ".") srcExt := strings.TrimPrefix(filepath.Ext(srcFile.FileName), ".")
if srcExt == "" { if srcExt == "" {
srcExt = defaultAudioExt srcExt = defaultAudioExt
} }
s.logger.Info("Converting file", src, err := s.fileRepo.Localize(*job.FileID)
"job_id", job.Id, if err != nil {
"src_path", srcFilePath, s.logger.Error("Failed to localize source file", "error", err, "file_id", *job.FileID)
"dest_path", destFilePath, return err
"src_format", srcExt) }
defer s.closeWork(src)
dest, err := s.fileRepo.StageEmpty(".ogg")
if err != nil {
s.logger.Error("Failed to stage converted file", "error", err, "job_id", job.Id)
return err
}
defer s.closeWork(dest)
s.logger.Info("Converting file", "job_id", job.Id, "src_format", srcExt)
// Измеряем время конвертации // Измеряем время конвертации
startTime := time.Now() startTime := time.Now()
err = s.converter.Convert(srcFilePath, destFilePath) err = s.converter.Convert(src.Path(), dest.Path())
conversionDuration := time.Since(startTime) conversionDuration := time.Since(startTime)
// Записываем метрику времени конвертации // Записываем метрику времени конвертации
@@ -214,43 +236,36 @@ func (s *TranscribeService) FindAndRunConversionJob() error {
"error", err, "error", err,
"job_id", job.Id, "job_id", job.Id,
"duration", conversionDuration) "duration", conversionDuration)
return s.failJob(job, err, "сбой конвертации файла") return s.failJob(job, holder, err, "сбой конвертации файла")
} }
stat, err := os.Stat(destFilePath) destSize, err := dest.Size()
if err != nil { if err != nil {
s.logger.Error("Failed to stat converted file", "error", err, "path", destFilePath) s.logger.Error("Failed to measure converted file", "error", err, "job_id", job.Id)
return err return err
} }
s.logger.Info("File conversion completed", s.logger.Info("File conversion completed",
"job_id", job.Id, "job_id", job.Id,
"duration", conversionDuration, "duration", conversionDuration,
"output_size", stat.Size()) "output_size", destSize)
// Записываем метрику размера выходного файла // Записываем метрику размера выходного файла
metrics.OutputFileSizeHistogram.WithLabelValues("ogg").Observe(float64(stat.Size())) metrics.OutputFileSizeHistogram.WithLabelValues("ogg").Observe(float64(destSize))
// Создаем запись в таблице files destFileName := fmt.Sprintf("%s%s", uuid.NewString(), ".ogg")
destFileRecord := &entity.File{ destFileRecord, err := s.fileRepo.CreateLocal(destFileName, dest)
Id: destFileId,
Storage: entity.StorageLocal,
FileName: destFileName,
Size: stat.Size(),
CreatedAt: time.Now(),
}
job.FileID = &destFileId
job.MoveToState(entity.StateConverted)
err = s.fileRepo.Create(destFileRecord)
if err != nil { if err != nil {
s.logger.Error("Failed to create converted file record", "error", err, "file_id", destFileId) s.logger.Error("Failed to create converted file record", "error", err, "job_id", job.Id)
return err return err
} }
err = s.jobRepo.Save(job) // Ссылка переставляется только после того, как запись о новом файле есть:
if err != nil { // иначе повтор оставил бы задачу указывающей на файл, которого нет.
job.FileID = &destFileRecord.Id
job.MoveToState(entity.StateConverted)
if err := s.jobRepo.Save(job, holder); err != nil {
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id) s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
return err return err
} }
@@ -259,36 +274,35 @@ func (s *TranscribeService) FindAndRunConversionJob() error {
return nil return nil
} }
func (s *TranscribeService) FindAndRunTranscribeJob() error { func (s *TranscribeService) transcribeJob(job *entity.TranscribeJob, holder string) error {
job, err := s.findJob(entity.StateConverted, time.Hour)
if err != nil {
return err
}
s.logger.Info("Starting transcribe job", "job_id", job.Id) s.logger.Info("Starting transcribe 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"), "у задачи нет записи")
}
fileRecord, err := s.fileRepo.GetByID(*job.FileID) fileRecord, err := s.fileRepo.GetByID(*job.FileID)
if err != nil { if err != nil {
s.logger.Error("Failed to get file record", "error", err, "file_id", *job.FileID) s.logger.Error("Failed to get file record", "error", err, "file_id", *job.FileID)
return err return err
} }
filePath := filepath.Join(s.storagePath, fileRecord.FileName) content, err := s.fileRepo.Open(*job.FileID)
file, err := os.Open(filePath)
if err != nil { if err != nil {
s.logger.Error("Failed to open file", "error", err, "path", filePath) s.logger.Error("Failed to open file", "error", err, "file_id", *job.FileID)
return err return err
} }
defer file.Close() defer func() {
if err := content.Close(); err != nil {
s.logger.Error("Failed to close file", "error", err, "file_id", *job.FileID)
}
}()
destFileId := uuid.NewString() s.logger.Info("Starting recognition", "job_id", job.Id, "file_id", *job.FileID)
destFileRecord := fileRecord.CopyWithStorage(destFileId, entity.StorageS3)
s.logger.Info("Starting recognition", "job_id", job.Id, "file_path", filePath)
// Запускаем асинхронное распознавание // Запускаем асинхронное распознавание
operationID, err := s.recognizer.Recognize(file, destFileRecord.FileName) operationID, err := s.recognizer.Recognize(content, fileRecord.FileName)
if err != nil { if err != nil {
s.logger.Error("Failed to start recognition", "error", err, "job_id", job.Id) s.logger.Error("Failed to start recognition", "error", err, "job_id", job.Id)
return err return err
@@ -298,20 +312,19 @@ func (s *TranscribeService) FindAndRunTranscribeJob() error {
"job_id", job.Id, "job_id", job.Id,
"operation_id", operationID) "operation_id", operationID)
// Обновляем задачу с ID операции распознавания destFileRecord, err := s.fileRepo.CreateRemote(fileRecord.FileName, fileRecord.Size)
job.FileID = &destFileId
job.RecognitionOpID = &operationID
delayTime := time.Now().Add(10 * time.Second)
job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
err = s.fileRepo.Create(destFileRecord)
if err != nil { if err != nil {
s.logger.Error("Failed to create S3 file record", "error", err, "file_id", destFileId) s.logger.Error("Failed to create S3 file record", "error", err, "job_id", job.Id)
return err return err
} }
err = s.jobRepo.Save(job) // Обновляем задачу с ID операции распознавания
if err != nil { job.FileID = &destFileRecord.Id
job.RecognitionOpID = &operationID
delayTime := time.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) s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
return err return err
} }
@@ -320,12 +333,7 @@ func (s *TranscribeService) FindAndRunTranscribeJob() error {
return nil return nil
} }
func (s *TranscribeService) FindAndRunTranscribeCheckJob() error { func (s *TranscribeService) checkTranscribeJob(job *entity.TranscribeJob, holder string) error {
job, err := s.findJob(entity.StateTranscribe, 24*time.Hour)
if err != nil {
return err
}
if job.RecognitionOpID == nil { if job.RecognitionOpID == nil {
s.logger.Error("Recognition operation ID not found", "job_id", job.Id) s.logger.Error("Recognition operation ID not found", "job_id", job.Id)
return fmt.Errorf("recogniton opId not found for job: %s", job.Id) return fmt.Errorf("recogniton opId not found for job: %s", job.Id)
@@ -342,12 +350,13 @@ func (s *TranscribeService) FindAndRunTranscribeCheckJob() error {
} }
if recResult.IsInProgress() { if recResult.IsInProgress() {
// Операция еще не завершена, оставляем в статусе обработки // Операция ещё не завершена. Шаг отработал без отказа, поэтому задержка
// здесь своя, числом, а число попыток обнуляется переходом: ожидание
// чужой операции попытку не тратит.
s.logger.Info("Operation in progress", "job_id", job.Id, "operation_id", opId) s.logger.Info("Operation in progress", "job_id", job.Id, "operation_id", opId)
delayTime := time.Now().Add(5 * time.Second) delayTime := time.Now().Add(nextCheckDelay)
job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime) job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
err := s.jobRepo.Save(job) if err := s.jobRepo.Save(job, holder); err != nil {
if err != nil {
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id) s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
return err return err
} }
@@ -360,7 +369,7 @@ func (s *TranscribeService) FindAndRunTranscribeCheckJob() error {
"job_id", job.Id, "job_id", job.Id,
"operation_id", opId, "operation_id", opId,
"error_message", errorText) "error_message", errorText)
return s.failJob(job, errors.New(errorText), "сбой при распознавании файла") return s.failJob(job, holder, errors.New(errorText), "сбой при распознавании файла")
} }
// Операция завершена, получаем результат // Операция завершена, получаем результат
@@ -376,86 +385,152 @@ func (s *TranscribeService) FindAndRunTranscribeCheckJob() error {
"text_length", len(transcriptionText)) "text_length", len(transcriptionText))
if len(transcriptionText) == 0 { if len(transcriptionText) == 0 {
return s.completeJob(job, "Ой, кажется, на аудиозаписи нет текста.") return s.completeJob(job, holder, "Ой, кажется, на аудиозаписи нет текста.")
} }
// Завершаем задачу // Завершаем задачу
return s.completeJob(job, transcriptionText) return s.completeJob(job, holder, transcriptionText)
} }
func (s *TranscribeService) findJob(state string, expiration time.Duration) (job *entity.TranscribeJob, err error) { // findJob забирает задачу и отдаёт её вместе с признаком захвата, который шаг
// держит. Задача, захваченная сверх предела попыток, до шага не доходит: её
// переводят в «мертва» и сообщают об этом отправителю.
func (s *TranscribeService) findJob(state string, expiration time.Duration) (*entity.TranscribeJob, string, error) {
acquisitionId := uuid.NewString() acquisitionId := uuid.NewString()
rottingTime := time.Now().Add(-1 * expiration) rottingTime := time.Now().Add(-1 * expiration)
job, err = s.jobRepo.FindAndAcquire(state, acquisitionId, rottingTime) job, err := s.jobRepo.FindAndAcquire(state, acquisitionId, rottingTime)
if err != nil { if err != nil {
// Признак узнаётся по смыслу: репозиторий вправе обернуть свой отказ // Признак узнаётся по смыслу: репозиторий вправе обернуть свой отказ
// пояснением, и приведение типа от этого сломалось бы молча. // пояснением, и приведение типа от этого сломалось бы молча.
var notFound *contract.JobNotFoundError var notFound *contract.JobNotFoundError
if errors.As(err, &notFound) { if errors.As(err, &notFound) {
return nil, &contract.NoopJobError{State: state} return nil, "", &contract.NoopJobError{State: state}
} }
s.logger.Error("Failed to find and acquire job", "state", state, "error", err) 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) return nil, "", fmt.Errorf("failed find and acquire job: %s, %w", state, err)
} }
return job, nil if job.Attempts > maxAttempts {
s.killJob(job, acquisitionId)
return nil, "", &contract.NoopJobError{State: state}
}
return job, acquisitionId, nil
} }
func (s *TranscribeService) completeJob(job *entity.TranscribeJob, transcriptionText string) error { // 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)
job.Die(fmt.Sprintf("attempts exhausted: %d", job.Attempts))
if err := s.jobRepo.Save(job, holder); err != nil {
s.logger.Error("Failed to save dead job", "error", err, "job_id", job.Id)
return
}
s.notify(job, "Не удалось обработать запись: попытки исчерпаны.\nПожалуйста, попробуйте еще раз.")
}
// scheduleRetry снимает захват с отказавшей задачи и ставит нарастающую паузу.
// Захват, оставленный до конца срока, отложил бы повтор на часы.
func (s *TranscribeService) scheduleRetry(job *entity.TranscribeJob, holder string, stepErr error) {
// Шаг, потерявший захват, задачу уже не трогает: ею занят другой.
var lost *contract.LostAcquisitionError
if errors.As(stepErr, &lost) {
return
}
job.RetryAfter(time.Now().Add(retryDelay(job.Attempts)))
if err := s.jobRepo.Save(job, 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)
}
}
// retryDelay растит паузу с числом попыток до потолка.
func retryDelay(attempts int) time.Duration {
if attempts < 1 {
attempts = 1
}
delay := time.Duration(math.Pow(2, float64(attempts-1))) * retryDelayBase
if delay > retryDelayCap || delay <= 0 {
return retryDelayCap
}
return delay
}
func (s *TranscribeService) completeJob(job *entity.TranscribeJob, holder string, transcriptionText string) error {
// Обновляем задачу с результатом // Обновляем задачу с результатом
job.Done(transcriptionText) job.Done(transcriptionText)
// Сохраняем задачу в базу // Сохраняем задачу в базу
err := s.jobRepo.Save(job) if err := s.jobRepo.Save(job, holder); err != nil {
if err != nil {
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id) s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
return fmt.Errorf("failed to save job: %w", err) return fmt.Errorf("failed to save job: %w", err)
} }
// Отправляем распознанный текст обратно пользователю // Отправляем распознанный текст обратно пользователю
switch job.Source { return s.send(job, transcriptionText)
case entity.SourceTelegram:
if job.TgChatId == nil {
s.logger.Error("Telegram chat not specified", "job_id", job.Id)
return fmt.Errorf("tg chat id not specified, job id: %s", job.Id)
}
err := s.tgSender.Send(transcriptionText, *job.TgChatId, job.TgReplyMessageId)
if err != nil {
s.logger.Error("Failed to sent transcription text to client", "job_id", job.Id)
return fmt.Errorf("failed to sent message to client, job id: %s, err: %w", job.Id, err)
}
}
return nil
} }
func (s *TranscribeService) failJob(job *entity.TranscribeJob, jobErr error, humanErrorText string) error { func (s *TranscribeService) failJob(job *entity.TranscribeJob, holder string, jobErr error, humanErrorText string) error {
// Обновляем задачу с результатом // Обновляем задачу с результатом
job.Fail(jobErr.Error()) job.Fail(jobErr.Error())
// Сохраняем задачу в базу // Сохраняем задачу в базу
err := s.jobRepo.Save(job) if err := s.jobRepo.Save(job, holder); err != nil {
if err != nil {
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id) s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
return fmt.Errorf("failed to save job: %w", err) return fmt.Errorf("failed to save job: %w", err)
} }
// Отправляем текст об ошибке пользователю errorMessage := fmt.Sprintf("При обработке задачи произошла ошибка: %s.\nПожалуйста, попробуйте еще раз.", humanErrorText)
switch job.Source { return s.send(job, errorMessage)
case entity.SourceTelegram: }
// send отвечает отправителю там, откуда пришла запись, и отказ отправки
// поднимает вверх: он принадлежит шагу.
func (s *TranscribeService) send(job *entity.TranscribeJob, text string) error {
if job.Source != entity.SourceTelegram {
return nil
}
if job.TgChatId == nil { if job.TgChatId == nil {
s.logger.Error("Telegram chat not specified", "job_id", job.Id) s.logger.Error("Telegram chat not specified", "job_id", job.Id)
return fmt.Errorf("tg chat id not specified, job id: %s", job.Id) return fmt.Errorf("tg chat id not specified, job id: %s", job.Id)
} }
errorMessage := fmt.Sprintf("При обработке задачи произошла ошибка: %s.\nПожалуйста, попробуйте еще раз.", humanErrorText) if err := s.tgSender.Send(text, *job.TgChatId, job.TgReplyMessageId); err != nil {
err := s.tgSender.Send(errorMessage, *job.TgChatId, job.TgReplyMessageId)
if err != nil {
s.logger.Error("Failed to sent message to client", "job_id", job.Id) 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) return fmt.Errorf("failed to sent message to client, job id: %s, err: %w", job.Id, err)
} }
}
return nil return nil
} }
// 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)
}
}
// closeWork убирает рабочую копию. Отказ уборки не роняет шаг, но и не
// проглатывается: забытая копия это шестичасовая запись во временном каталоге.
func (s *TranscribeService) closeWork(work contract.WorkFile) {
if err := work.Close(); err != nil {
s.logger.Error("Failed to remove work file", "error", err)
}
}
+77 -83
View File
@@ -2,8 +2,7 @@ package main
import ( import (
"context" "context"
"database/sql" "errors"
"embed"
"flag" "flag"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -17,29 +16,17 @@ import (
ffmpegconv "git.vakhrushev.me/av/transcriber/internal/adapter/converter/ffmpeg" ffmpegconv "git.vakhrushev.me/av/transcriber/internal/adapter/converter/ffmpeg"
ffmpegmv "git.vakhrushev.me/av/transcriber/internal/adapter/metaviewer/ffmpeg" ffmpegmv "git.vakhrushev.me/av/transcriber/internal/adapter/metaviewer/ffmpeg"
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer/yandex" "git.vakhrushev.me/av/transcriber/internal/adapter/recognizer/yandex"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite" pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram" "git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
"git.vakhrushev.me/av/transcriber/internal/config" "git.vakhrushev.me/av/transcriber/internal/config"
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http" httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg" tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg"
"git.vakhrushev.me/av/transcriber/internal/controller/worker" "git.vakhrushev.me/av/transcriber/internal/controller/worker"
"git.vakhrushev.me/av/transcriber/internal/service" "git.vakhrushev.me/av/transcriber/internal/service"
"github.com/doug-martin/goqu/v9"
_ "github.com/doug-martin/goqu/v9/dialect/sqlite3"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv" "github.com/joho/godotenv"
_ "github.com/mattn/go-sqlite3" "github.com/pocketbase/pocketbase/apis"
"github.com/pressly/goose/v3" "github.com/pocketbase/pocketbase/core"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
sloggin "github.com/samber/slog-gin"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
const (
ServerShutdownTimeout = 5
ForceShutdownTimeout = 20
) )
func main() { func main() {
@@ -68,35 +55,25 @@ func main() {
logger.Warn("Warning: .env file not found, using system environment variables") logger.Warn("Warning: .env file not found, using system environment variables")
} }
// Создаем директории если они не существуют // Хранилище поднимается библиотекой, а не её набором команд: разбор флагов
if err := os.MkdirAll(cfg.Storage.Path, 0750); err != nil { // и мягкая остановка остаются нашими. Схему накатывает Serve — он гоняет
logger.Error("Failed to create file storage directory", "path", cfg.Storage.Path, "error", err) // непринятые шаги прежде, чем поднять сервер.
os.Exit(1) storage, err := pbrepo.New(cfg.Storage.DataDir)
}
db, err := sql.Open("sqlite3", cfg.Database.Path)
if err != nil { if err != nil {
logger.Error("failed to open database", "error", err) logger.Error("Failed to open storage", "error", err)
os.Exit(1) os.Exit(1)
} }
defer db.Close() defer func() {
if err := storage.ResetBootstrapState(); err != nil {
if err := db.Ping(); err != nil { logger.Error("Failed to close storage", "error", err)
logger.Error("failed to ping database", "error", err)
os.Exit(1)
} }
}()
gq := goqu.New("sqlite3", db) pbrepo.BindPanelRules(storage)
// Запускаем миграции
if err := RunMigrations(db, logger); err != nil {
logger.Error("Failed to run migrations", "error", err)
os.Exit(1)
}
// Создаем репозитории // Создаем репозитории
fileRepo := sqlite.NewFileRepository(db, gq) fileRepo := pbrepo.NewFileRepository(storage)
jobRepo := sqlite.NewTranscriptJobRepository(db, gq) jobRepo := pbrepo.NewTranscriptJobRepository(storage)
// Создаем адаптеры // Создаем адаптеры
metaviewer := ffmpegmv.NewFfmpegMetaViewer() metaviewer := ffmpegmv.NewFfmpegMetaViewer()
@@ -139,7 +116,6 @@ func main() {
converter, converter,
recognizer, recognizer,
tgSender, tgSender,
cfg.Storage.Path,
logger, logger,
) )
@@ -193,49 +169,74 @@ func main() {
}(w) }(w)
} }
// Создаем Gin middleware для логирования // Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
gin.SetMode(gin.DebugMode) // и второму серверу на нём взяться неоткуда.
router := gin.New() transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService, logger)
router.Use(sloggin.New(logger))
router.Use(gin.Recovery())
// Запускаем HTTP сервер для API (создание задач и проверка статуса) // Сервер приезжает каналом, а не общей переменной: хук исполняется в
transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService) // горутине сервера, а читает его горутина остановки, и связи «произошло
// раньше» между ними иначе нет.
srvCh := make(chan *http.Server, 1)
storage.OnServe().BindFunc(func(se *core.ServeEvent) error {
// Шесть часов записи по медленному каналу переживают любой фиксированный
// таймаут чтения, а умолчание хранилища — пять минут. Стойкость к
// целенаправленной нагрузке объявлена вне модели угроз проекта.
se.Server.ReadTimeout = 0
srvCh <- se.Server
// Настраиваем роуты только для создания задач и проверки статуса // Журнал входящих запросов вернулся своим слоем: вместе с gin ушёл
api := router.Group("/api") // `sloggin`, а хранилище пишет запросы в свою таблицу, которой в
{ // журнале контейнера не видно. Поля — те, что просит конвенция.
api.POST("/audio", transcribeHandler.CreateTranscribeJob) se.Router.BindFunc(func(e *core.RequestEvent) error {
api.GET("/status/:id", transcribeHandler.GetTranscribeJobStatus) start := time.Now()
err := e.Next()
level := slog.LevelInfo
if e.Request.URL.Path == "/health" || e.Request.URL.Path == "/metrics" {
// Опрос здоровья и метрик идёт постоянно и полезного не несёт.
level = slog.LevelDebug
} }
// Добавляем middleware для обработки больших файлов logger.Log(e.Request.Context(), level, "Incoming request",
router.MaxMultipartMemory = 32 << 20 // 32 MiB "http.method", e.Request.Method,
"http.route", e.Request.URL.Path,
"http.status_code", e.Status(),
"duration_ms", time.Since(start).Milliseconds(),
"transport", "http")
// Добавляем базовый роут для проверки работоспособности return err
router.GET("/health", func(c *gin.Context) { })
c.JSON(200, gin.H{
transcribeHandler.Register(se.Router)
se.Router.GET("/health", func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]string{
"status": "ok", "status": "ok",
"message": "Transcriber service is running", "message": "Transcriber service is running",
}) })
}) })
// Добавляем эндпоинт для метрик Prometheus se.Router.GET("/metrics", func(e *core.RequestEvent) error {
router.GET("/metrics", gin.WrapH(promhttp.Handler())) promhttp.Handler().ServeHTTP(e.Response, e.Request)
return nil
})
// Создаем HTTP сервер return se.Next()
srv := &http.Server{ })
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
Handler: router,
}
// Запускаем HTTP сервер в отдельной горутине // Запускаем HTTP сервер в отдельной горутине
serveErr := make(chan error, 1)
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
logger.Info("Starting HTTP server", "port", cfg.Server.Port) logger.Info("Starting HTTP server", "port", cfg.Server.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { err := apis.Serve(storage, apis.ServeConfig{
HttpAddr: fmt.Sprintf(":%d", cfg.Server.Port),
ShowStartBanner: false,
})
if err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("HTTP server error", "error", err) logger.Error("HTTP server error", "error", err)
serveErr <- err
} }
}() }()
@@ -247,9 +248,13 @@ func main() {
logger.Info("Workers: ConversionWorker, TranscribeWorker, CheckWorker") logger.Info("Workers: ConversionWorker, TranscribeWorker, CheckWorker")
logger.Info("Press Ctrl+C to stop...") logger.Info("Press Ctrl+C to stop...")
// Ждем сигнал завершения // Ждем сигнал завершения либо отказ сервера
<-sigChan select {
case <-sigChan:
logger.Info("Received shutdown signal, initiating graceful shutdown...") logger.Info("Received shutdown signal, initiating graceful shutdown...")
case <-serveErr:
logger.Error("HTTP server stopped unexpectedly, shutting down")
}
if tgController != nil { if tgController != nil {
logger.Info("Shutting down Telegram bot...") logger.Info("Shutting down Telegram bot...")
@@ -261,12 +266,17 @@ func main() {
defer shutdownCancel() defer shutdownCancel()
// Останавливаем HTTP сервер // Останавливаем HTTP сервер
select {
case srv := <-srvCh:
logger.Info("Shutting down HTTP server...") logger.Info("Shutting down HTTP server...")
if err := srv.Shutdown(shutdownCtx); err != nil { if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error("HTTP server forced to shutdown", "error", err) logger.Error("HTTP server forced to shutdown", "error", err)
} else { } else {
logger.Info("HTTP server stopped gracefully") logger.Info("HTTP server stopped gracefully")
} }
default:
logger.Info("HTTP server was not started, nothing to shut down")
}
// Отменяем контекст для остановки воркеров // Отменяем контекст для остановки воркеров
cancel() cancel()
@@ -288,19 +298,3 @@ func main() {
logger.Info("Transcriber service stopped") logger.Info("Transcriber service stopped")
} }
func RunMigrations(db *sql.DB, logger *slog.Logger) error {
if err := goose.SetDialect("sqlite3"); err != nil {
return fmt.Errorf("failed to set goose dialect: %w", err)
}
// Use the embedded filesystem for migrations
goose.SetBaseFS(migrationsFS)
if err := goose.Up(db, "migrations"); err != nil {
return fmt.Errorf("failed to run migrations: %w", err)
}
logger.Info("Migrations completed successfully")
return nil
}
-11
View File
@@ -1,11 +0,0 @@
-- +goose Up
CREATE TABLE files (
id TEXT PRIMARY KEY,
storage TEXT NOT NULL,
file_name TEXT NOT NULL,
size INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- +goose Down
DROP TABLE files;
@@ -1,24 +0,0 @@
-- +goose Up
CREATE TABLE transcribe_jobs (
id TEXT PRIMARY KEY,
state TEXT NOT NULL,
delay_time DATETIME,
file_id TEXT,
recognition_op_id TEXT,
transcription_text TEXT,
acquisition_id TEXT,
acquire_time DATETIME,
is_error BOOLEAN NOT NULL,
error_text TEXT,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (file_id) REFERENCES files(id)
);
-- +goose Down
DROP TABLE transcribe_jobs;
@@ -1,9 +0,0 @@
-- +goose Up
ALTER TABLE transcribe_jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'unknown';
ALTER TABLE transcribe_jobs ADD COLUMN tg_chat_id INTEGER;
ALTER TABLE transcribe_jobs ADD COLUMN tg_reply_message_id INTEGER;
-- +goose Down
ALTER TABLE transcribe_jobs DROP COLUMN source;
ALTER TABLE transcribe_jobs DROP COLUMN tg_chat_id;
ALTER TABLE transcribe_jobs DROP COLUMN tg_reply_message_id;
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-11
@@ -0,0 +1,454 @@
## Context
Способ решения выбран до этой задачи, и переписывать его здесь незачем:
хранилищем становится PocketBase вместе с файлами и панелью
([ADR-2026-08-11-pocketbase-storage-with-admin-panel](../../../docs/adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md),
замер — [research/pocketbase.md](../../../docs/research/pocketbase.md)), очередь
остаётся своей таблицей, но коллекцией той же базы
([ADR-2026-08-11-queue-as-pocketbase-collection](../../../docs/adr/ADR-2026-08-11-queue-as-pocketbase-collection.md),
сравнение кандидатов — [research/job-queue.md](../../../docs/research/job-queue.md)).
Ниже — только то, что этими решениями не закрыто.
Сегодня состояние лежит в SQLite через `mattn/go-sqlite3`, запросы строит goqu,
схему двигает goose, файлы лежат плоским каталогом с именами по идентификатору,
а HTTP отдаёт gin. Версия PocketBase — та же, что мерила разведка: 0.39.10.
## Goals / Non-Goals
**Goals:**
- Запись, её метаданные и её файл лежат в одном хранилище и видны владельцу
панелью.
- Захват задачи неделим, у задачи есть предел попыток и состояние «мертва».
- Сборка перестаёт требовать CGO.
- Сервис поднимается на чистом каталоге сам.
**Non-Goals:**
- **Перенос прежних данных.** Не переносим и не пытаемся прочитать: решение
задачи, а не следствие отказа.
- **Вход пользователей.** Провайдер OIDC у коллекции пользователей — задача
`oidc-login`.
- **Закрытие панели снаружи.** Это работа выкладки: адрес панели закрывает
Authelia на обратном прокси.
- **Отказ от холостого опроса.** Три воркера по-прежнему опрашивают базу раз в
секунду; открытый вопрос архитектуры остаётся открытым.
- **Удаление файлов и объектов.** Хранение бессрочно, задача `delete-record`
своя.
- **Своё резервное копирование.** Берём мы встроенное или нет — вопрос не
решён и здесь не решается.
## Decisions
### Приложение поднимает PocketBase само, а не отдаёт ему командную строку
Библиотека умеет запускаться двумя способами: `Start()` отдаёт процесс её
собственному набору команд, а `Bootstrap()` плюс `apis.Serve()` оставляет
управление нам.
Берём второй. Первый забирает разбор флагов себе, и ключ `-c config.toml`,
объявленный в командах проекта, пришлось бы либо ломать, либо подпирать. Мягкая
остановка по сигналу с двумя таймаутами из конфигурации — тоже наша, и отдавать
её чужой команде не за что.
Цена названа: набора команд PocketBase у нас не появляется, а вместе с ним нет и
команды заведения владельца панели. Чем это закрыто — ниже.
*Отвергнуто:* `Start()` с подстройкой флагов — экономит десяток строк и ломает
объявленный контракт запуска.
### Наши два обработчика переезжают на роутер PocketBase, gin уходит
Панель отдаётся тем же портом, что и приложение, — так решено ADR. Значит порт
слушает сервер PocketBase, и второму серверу на том же порту взяться неоткуда.
Наши маршруты — `POST /api/audio`, `GET /api/status/:id`, `GET /health`,
`GET /metrics` — переезжают в его роутер обработчиком на `OnServe`. Столкновения
имён нет: PocketBase занимает `/api/collections`, `/api/files`, `/api/settings`,
`/api/logs`, `/api/backups`, `/api/crons`, `/api/realtime`, `/api/batch` и
`/api/health`, а `/api/audio` и `/api/status/:id` свободны. Публичный контракт
API от этого не меняется — меняется то, кто его обслуживает.
Вместе с gin уходит `samber/slog-gin`, и запросы начинает писать журнал
PocketBase. Проверки приёма по HTTP переписываются под новый обработчик; их
предмет — коды, поля ответа и запрет имени отправителя в журнале — сохраняется
дословно.
*Отвергнуто:* **два сервера на разных портах** — наружу опубликован один порт, и
панель осталась бы недоступной либо потребовала бы второй маршрут на прокси;
ADR решил иначе. **gin общим обработчиком под роутером PocketBase** — маршруты
разбирались бы дважды, а совпадение с чужим путём проявилось бы как молчаливый
перехват.
### Схему заводят миграции PocketBase, каталог `migrations/` уходит
Коллекции `files` и `transcribe_jobs` заводит зарегистрированная миграция на Go;
`apis.Serve` применяет непринятые перед стартом сервера. Инвариант проекта
«миграция, уехавшая на сервер, не переписывается» переносится дословно: файл
шага не правится, изменение — только новым файлом.
Goose, goqu и `mattn/go-sqlite3` уходят из зависимостей вместе с каталогом
`migrations/*.sql` и вшиванием его в бинарник.
### Состав коллекций
`files` — по записи на физическую копию, как и сегодня:
| Поле | Что |
| --- | --- |
| `file` | сам файл; пусто у копии в Object Storage |
| `location` | `local` или `s3` |
| `object_key` | ключ объекта; пусто у местной копии |
| `size` | размер в байтах |
Поле названо `location`, а не `storage`, как сегодня, потому что после перевода
слово `storage` занято дважды: так зовётся capability и так зовут само хранилище.
Третий смысл в поле записи развёл бы `storage.FileRepository` и `entity.StorageS3`
по разным вещам под одним словом, и увидеть это в коде было бы нечем.
`transcribe_jobs` — задача и она же очередь. Поля сегодняшней таблицы переезжают
один в один, кроме трёх мест:
- `is_error` **уходит**. Задача выбывает из выборки состоянием, и способ этот
один: два способа разошлись бы, и молчаливо потерялся бы тот, который забыли
проверить;
- `state` получает значение `dead`;
- прибавляется `attempts` — число попыток.
Идентификаторы записей выдаёт PocketBase — 15 знаков собственного алфавита. Наши
UUID уходят: два источника идентификатора в одной таблице дают два формата
ссылки на одну сущность. Ответ `POST /api/audio` при этом продолжает нести
`job_id` строкой — контракт говорит о поле, а не о длине значения.
**Инвариант «новая колонка правится в четырёх местах» остаётся, но переезжает.**
Мест по-прежнему четыре — отображение задачи в запись и обратно, перечень колонок
захвата и структура, в которую он читает, — и все четыре лежат в одном пакете, а
не в четырёх запросах разных слоёв. Условие, ради которого инвариант писался, при
этом не снято: компилятор видит два места из четырёх, и колонка, забытая в паре
«перечень — структура», приезжает из захвата нулевой, а первый же `Save` пишет
этот ноль поверх сохранённого. Теряется поле **только у задачи, попавшей к
воркеру**, — то есть тише, чем прежде.
### Захват — один запрос с `RETURNING` мимо записей коллекции
```
UPDATE transcribe_jobs
SET acquisition_id = ?, acquire_time = ?, attempts = attempts + 1, updated = ?
WHERE id = (SELECT id FROM transcribe_jobs
WHERE state = ? AND (delay_time IS NULL OR delay_time < ?)
AND (acquisition_id IS NULL OR acquire_time < ?)
ORDER BY created LIMIT 1)
RETURNING <колонки>
```
`RETURNING` в движке за `modernc.org/sqlite` есть, и замер разведки показал: на
трёх горутинах разом запись получает ровно одна. Запрос идёт через `app.DB()`,
который всё, кроме выборок, направляет в пул с единственным соединением, — то
есть захваты выстраиваются в очередь, а не соревнуются за файл.
`ORDER BY` идёт по времени заведения **и по ключу записи**: время неуникально, и
без ключа порядок обработки невоспроизводим, а проверка, опирающаяся на
«следующую» задачу, зелена через раз.
**Время во всех колонках очереди — то же, каким хранилище пишет свои
`created`/`updated`:** строка `2006-01-02 15:04:05.000Z` в UTC (`types.DateTime`
библиотеки). Наш запрос кладёт и сравнивает `acquire_time`, `delay_time` и
`updated` только через это же значение. Причина не в аккуратности: сравнение
строк в SQLite побайтовое, и вид, разошедшийся на разделителе или на дробной
части, обращает `acquire_time < ?` в постоянную истину — тогда любая захваченная
задача немедленно достаётся второму воркеру — или в постоянную ложь — тогда
брошенная задача не возвращается никогда. Оба исхода тихие, и тест, который сам
же кладёт время своим кодом, зелен в обоих.
Хуки коллекции на сыром запросе не срабатывают — цена названа в ADR; поле
времени изменения проставляет тот же запрос.
*Отвергнуто:* **захват записями коллекции** — это снова два шага без транзакции,
ровно то, от чего уходим. **Захват в транзакции PocketBase** — даёт то же
свойство дороже: транзакция на каждый холостой опрос, которых 259 200 в сутки.
### Результат пишет только держатель захвата
Неделимость захвата не закрывает всего: захват протухает не только у мёртвого
воркера, но и у живого. Конвертация шестичасовой записи идёт дольше часа по
построению, а срок захвата на конвертацию сегодня — час.
Отсюда две правки. Первая: **сроки захвата привязываются к потолку своего шага**,
и в таблице настроек стоят рядом с ним. Вторая, и она важнее: **запись результата
условна по признаку захвата** — шаг, чей захват за время работы достался другому,
завершается без записи и без ответа отправителю.
Без второго два воркера пишут в одну задачу по очереди: результат первого
затирает результат второго, файл второго остаётся сиротой, распознавание уходит в
Yandex дважды за наши деньги, а отправитель получает два ответа на одну запись.
### Мёртвая задача, попытки и пауза
Три вещи, которые легко свести в одну и нельзя: **счётчик попыток**, **пауза
повтора** и **задержка опроса чужой операции**.
**Счётчик** растёт при каждом захвате и обнуляется, когда шаг завершился без
отказа. Рост при захвате, а не при отказе, — единственное, что засчитывает
попытку задаче, уносящей с собой процесс: до объявления отказа такая задача не
доходит никогда, и по счётчику отказов крутилась бы вечно. Обнуление при успехе
делает то же самое с другой стороны: задача, прошедшая конвейер, попыток не
копит и до предела не добирается.
**Кто переводит в «мертва».** Тот, кто захватил задачу с превышенным счётчиком:
захват её выдаёт, вызывающий видит перебор, ставит `dead`, сообщает отправителю и
возвращает «работы нет». Условие `attempts < предел` прямо в отборе не годится —
задача исчезла бы из выборки, не получив состояния, то есть выбыла бы молча.
**Отправителю сообщается.** Переход в «мертва» идёт тем же путём, что отказ шага:
инвариант «Принятая запись не теряется молча» допускает два исхода — либо задача
пригодна к повтору, либо о неудаче сказано, — и молчаливая смерть не подходит ни
под один.
**`dead` и `failed` — разные приговоры, а не два имени одного.** В `failed`
задачу переводит шаг, рассудивший об этой записи окончательно: файл не
конвертируется, распознавание вернуло ошибку. В `dead` задача уходит без такого
суждения: мы повторяли и перестали. Ни один шаг конвейера в `dead` не переводит
сам, и выбирать между двумя ему не приходится.
**Пауза повтора** — функция счётчика, ставится в момент отказа. **Задержка опроса
чужой операции** — число, ставится шагом проверки, и попытку он не тратит, потому
что отработал без отказа. Свести их было бы ошибкой ровно потому, что счётчик на
ожидании обнулён: пауза выродилась бы в своё наименьшее значение, и опрос
SpeechKit участился бы с пяти секунд до одной — вчетверо больше обращений к
платному сервису, а отказ по его лимиту тратит попытки уже по-настоящему.
Числа — в таблицу настроек `docs/database.md`:
| Настройка | Значение | Откуда |
| --- | --- | --- |
| Предел попыток | 5 | — |
| Пауза перед повтором | `2^(попытка−1)` секунд, потолок 5 минут | — |
| Срок захвата, конвертация | 8 часов | потолок записи 6 часов плюс запас |
| Срок захвата, проверка операции | 1 час | опрос идёт секунды |
| Задержка перед первой проверкой операции | 10 секунд | как сегодня |
| Задержка между проверками операции | 5 секунд | как сегодня |
Из вариантов эти числа не выбирались, и это честнее назвать, чем оправдать:
предел 5 и удвоение паузы — обычное умолчание, а не вывод из замера. Позволительно
потому, что числа обратимы — они живут в одной таблице и правятся строкой, в
отличие от имени ключа конфигурации и раскладки файлов.
### Имя файла в хранилище задаём мы, а не хранилище
Умолчание PocketBase строит имя из имени, данного отправителем: `sample.ogg`
превращается в `sample_uztrv6wvz3.ogg` — так это замерила разведка, и так это
предсказали ADR и модель угроз.
**Умолчание не берём.** Спека `intake` уже нормирует обратное: имя отправителя в
хранилище не попадает, потому что имя файла кончается в журнале, а имя
отправителя в журнал не пишется по инварианту приватности. Имя файла у библиотеки
— обычное поле, и мы ставим в него своё: идентификатор с расширением, как
сегодня.
**Суффикса при этом не появляется**, и это выяснилось прогоном: десять случайных
знаков дописывает не укладка, а тот самый конструктор имени, который мы обходим.
Значит имя в хранилище равно заданному, и защищает ссылку не суффикс, а то, что
имени в журнале нет вовсе.
Изъятие из инварианта — расширение, хвост после последней точки — остаётся ровно
таким, каким объявлено, и не расширяется до полного имени.
### Файл кладётся потоком, а рабочая копия заводится одним способом
Расчётный потолок записи — шесть часов, и в память такая запись не помещается.
Библиотека умеет строить файл из пути на диске и читает его потоком. Значит приём
пишет тело во временный файл, отдаёт его хранилищу и убирает за собой; каталог
временных файлов — общесистемный, не `data/`.
Обратная сторона — та же и упускается легче. **Конвертер и чтение метаданных
принимают путь**, потому что отдают файл внешней программе: `ffmpeg` и `ffprobe`
получают имя аргументом. Хранилище пути наружу не даёт, значит между ними нужна
рабочая копия — и вот её-то и надо завести **одним местом**, а не по месту в
каждом шаге.
Место это — сам репозиторий файлов: он выдаёт рабочую копию и единственный
способ её убрать, а зовёт уборку шаг. Полностью замкнуть уборку на репозиторий —
вызовом шага изнутри — мешает конвертация: ей нужны две копии разом, исходник и
результат, и вложенные вызовы читались бы хуже, чем два `defer` подряд. Цена
названа: норма держится проверкой, а не построением, и проверки на уборку есть у
приёма и у шага конвертации.
*Отвергнуто:* **построение файла из байтов в памяти** — проще на строку и роняет
процесс на первой же длинной записи. **Путь внутрь раскладки хранилища, отданный
`ffmpeg` напрямую** — раскладка библиотеки становится нашим контрактом, а
требование «файл адресуется записью» не выполняется с первого дня и молча.
**Перевод конвертера и `ffprobe` на потоки** — дороже всего и упирается в то, что
длительность из потока `ffprobe` отдаёт не всегда.
### Панель — вход в задачу, а не окно просмотра
Ради правки задачи панель и покупалась: мёртвая задача оживляется сменой
состояния, а не запросом в консоли сервера. Но правка полем в панели идёт мимо
кода, который сегодня чистит служебные поля прошлого состояния, — и владелец,
«вернувший задачу в работу», получил бы задачу с прежним признаком захвата
(захвату она не выдастся до конца срока) и с числом попыток на пределе (умрёт от
первого отказа). Он бы об этом не узнал.
Поэтому переход, сделанный в панели, проходит те же правила, что переход из кода:
на правку записи задачи вешается хук, который при смене состояния чистит признак
захвата, время захвата, паузу и число попыток. Единая точка перехода остаётся
одна, и панель ходит через неё.
Схема при этом держит то, что сегодня держит компилятор: ссылка на файл
обязательна, перечень состояний закрыт, число попыток неотрицательно. Задача,
заведённая в панели руками, не должна ронять процесс на разыменовании пустой
ссылки — а сегодня уронила бы, и вместе с воркером ушли бы бот и приём по HTTP.
*Отвергнуто:* **панель только для чтения по этой коллекции** — отнимает ровно то,
ради чего перевод затевался. **Оставить как есть** — перекладывает на владельца
знание о четырёх служебных полях, и первая же ошибка тихо ломает задачу.
### Потолок размера назван числом, потому что чужие умолчания малы
Прогон показал то, что чтением не видно: нулевой потолок у поля файла библиотека
читает не как «без предела», а как своё умолчание в **5 МиБ**, а роутер
хранилища отсекает тело запроса на **32 МиБ** раньше нашего обработчика. Оба
умолчания на два-три порядка меньше расчётной записи в шесть часов: приём
отказывал бы на всём длиннее примерно пяти минут, а уже принятая запись
исчерпывала бы попытки на шаге конвертации — результат в ogg переваливает 5 МиБ
примерно на пятой минуте.
Поэтому потолок задан числом и одним: `entity.MaxRecordSize`, 8 ГиБ, выведено из
шести часов с запасом на видео. Тем же числом ограничено тело запроса приёма.
Заодно снят таймаут чтения — умолчание в пять минут не переживает заливку
шестичасовой записи по медленному каналу, а стойкость к целенаправленной
нагрузке объявлена вне модели угроз.
### Правила панели стоят на правке запросом, а не на всяком сохранении
Первая редакция вешала их модельным событием, и это оказалось дефектом: событие
не различает, кто пишет, и срабатывало на каждом переходе конвейера. Задержка,
поставленная шагом вместе со сменой состояния, стиралась тем же сохранением —
опрос платного распознавания уходил через секунду вместо десяти, — а число
попыток мёртвой задачи, которое переход хранит намеренно, приходило владельцу
нулём.
Событие правки **запросом** различает источник по построению: конвейер пишет
мимо HTTP-слоя и под него не попадает.
### Поле файла не помечаем защищённым, но ссылка не уезжает в журнал
Защищённое поле требует отдельного файлового токена. Не помечаем: сегодня право
прочитать задачу даёт знание её идентификатора, и файл встаёт вровень с
`GET /api/status/:id`, а не ниже. Правила доступа коллекций при этом остаются
пустыми — то есть перечислить записи может только владелец панели, и подобрать
идентификатор снаружи неоткуда.
**Отсюда следствие, которого не было при плоском каталоге, и оно меняет смысл
изъятия из инварианта приватности.** Изъятие выписано под путь на диске:
`data/files/<uuid>.ogg` читателю журнала бесполезен. После перевода имя файла в
хранилище — это последняя часть ссылки `/api/files/...`, по которой запись
скачивает кто угодно; строка журнала стала бы бессрочным ключом к чужому аудио.
Поэтому **в журнал идёт расширение собственным полем**, а имя файла — ни в каком
виде. Прослеживаемость от этого не страдает: требование `intake` просит
идентификатор, расширение и размер, и все три остаются. Изъятие остаётся ровно
таким, каким объявлено: расширение, и только оно.
**Отказы обрываются там же.** Отказ чтения из хранилища несёт ключ файла целиком,
отказ выгрузки в Object Storage — полный адрес объекта; обе цепочки `%w` уехали
бы в журнал и собрали бы ссылку не хуже успешного пути. Поэтому наружу идёт свой
текст с идентификатором записи, а чужой не оборачивается.
Цена названа: ссылка на файл, единожды утёкшая, работает без ограничения по
времени. Разграничение доступа целиком — задачи `oidc-login` и
`record-ownership`, и до них периметр таков, каким его описывает модель угроз.
### Ссылку на файл строит панель, а не наш контракт
Потребителя у ссылки внутри сервиса нет: ответ опроса готовности её не несёт,
распознавание берёт содержимое, а панель строит ссылку сама. Поэтому метода
«построй ссылку» в договоре ядра с хранилищем **не заводим** — иначе форма
HTTP-пути протекла бы в доменный контракт, а знать о протоколе хранилищу незачем.
Требование «файл отдаётся ссылкой» при этом остаётся: оно нормирует свойство
хранилища, а проверяется прогоном — запросом за файлом и сверкой длины. Первым
потребителем ссылки станет приложение, и заведёт её себе оно.
### Владелец панели заводится ссылкой при первом запуске
Команды заведения владельца у нас нет — её забрал отказ от чужой командной
строки. Библиотека закрывает это сама: пока владелец не заведён, при старте
сервера она печатает ссылку установки, по которой владелец задаёт себе почту и
пароль. Ссылка идёт в журнал контейнера, а журнал читает владелец сервиса.
Ссылка равносильна паролю от панели, поэтому у неё два ограничения, и оба у
библиотеки уже есть: **тридцать минут жизни** (`NewStaticAuthToken(30*time.Minute)`)
и печать **только пока владельца нет**. Проверено чтением её кода;
подтвердить прогоном — шаг приёмки. Бессрочная ссылка в журнале отдала бы панель
всякому читателю логов навсегда — при инварианте «строки уже уехали в журнал
контейнера» это необратимо.
Ключа конфигурации под пароль не появляется, и это осознанно: секрет, которого в
конфигурации нет, не утекает вместе с ней. Хранилище держит только отпечаток.
*Отвергнуто:* **пароль ключом конфигурации** — заводит в конфигурации самый
чувствительный секрет проекта и ставит его в один ряд с токеном бота, тогда как
хранилище умеет обойтись отпечатком.
### Ключи конфигурации: два пути заменяются одним каталогом
`[database] path` и `[storage] path` уходят: база и файлы съезжаются под один
каталог, и по-другому хранилище не умеет.
**Имя ключа конфигурации проект объявил необратимым**, поэтому решение принял
человек 2026-08-11: **`[storage] data_dir` со значением `data`**. Варианты и цена
каждого:
- `[storage] data_dir`**выбрано**. Ключ назван по назначению, как названы и
сегодняшние; смена библиотеки через год имени не тронет. Слово `storage` при
этом уже занято capability, но в конфигурации оно значит ровно то же — где
лежат данные;
- `[pocketbase] data_dir` — прямее всего читается тем, кто знает библиотеку, и
вписывает имя поставщика в необратимый ключ. Смена библиотеки потребует второго
необратимого переименования;
- `[data] dir` — короче и нейтральнее всех, но `data` в проекте уже значит
каталог на диске, и секция с таким именем читается как «настройки каталога», а
не «настройки хранилища».
## Risks / Trade-offs
- **Правила доступа коллекций оставлены пустыми, а сама база публикует
`/api/collections/...` и служебные разделы наружу** → пустое правило значит
«только владелец панели», то есть анонимный запрос к записям получает отказ.
Проверяется прогоном на живом сервисе, а не рассуждением, и прогон этот —
отдельный шаг приёмки.
- **Панель висит на публичном порту** → закрывает её Authelia на обратном прокси;
это работа выкладки, и до неё панель открыта всякому, кто знает адрес. Записано
моделью угроз, задачи в беклоге нет намеренно.
- **Захват идёт сырым запросом мимо записей коллекции** → правка состава колонок
очереди перестаёт быть видной компилятору в этом одном месте. Держится тестом
захвата, который читает захваченную задачу целиком.
- **Число попыток растёт при захвате** → задача, которую бросают по независящей от
неё причине (перезапуск сервиса), тратит попытки. Смягчение: счётчик обнуляется
на каждом шаге, завершившемся без отказа, поэтому пять перезапусков подряд
должны прийтись на одну и ту же задачу, чтобы её убить.
- **Задача умирает молча, если сообщение отправителю не дошло** → переход в
«мертва» отвечает тем же путём, что и отказ, и отказ отправки логируется так же.
Гарантии доставки у нас нет ни там, ни там, и этой задачей она не заводится.
- **Проверки приёма по HTTP переписываются целиком** → предмет проверок при этом
не меняется, и расхождение поймает сравнение с прежним списком сценариев спеки
`intake`.
- **Идентификаторы задач меняют формат** → внешняя программа, хранящая прежние
идентификаторы, их не найдёт. Прежних данных нет по решению задачи, поэтому
цена нулевая — но названа, потому что при переносе данных была бы не нулевой.
## Migration Plan
Переноса нет. Сервис поднимается на чистом каталоге данных; момент перехода на
сервере назначает человек, и до него прежний каталог остаётся нетронутым.
Откат — возврат прежнего образа и прежнего каталога `data/`: новый каталог
данных заводится рядом, старого не трогает.
## Open Questions
- **Своё резервное копирование PocketBase** — берём или оставляем серверу;
открытый вопрос архитектуры, этой задачей не закрывается.
- **Отказ от холостого опроса** — 259 200 запросов в сутки посчитаны, цена не
измерена; вопрос остаётся открытым.
@@ -0,0 +1,60 @@
## Why
Записи, их метаданные и сами файлы лежат порознь, и владелец сервиса не видит их
ничем, кроме консоли на сервере: чтобы посмотреть задачу или послушать запись,
он идёт руками в базу и в каталог на диске. Заодно принятая запись держится на
захвате из двух шагов подряд, между которыми задачу может перехватить соседний
воркер, а задача, падающая на каждой попытке, падает вечно и никем не считается.
## What Changes
- Записи, их метаданные и файлы съезжаются в одно хранилище, и владелец получает
панель, где видит задачу строкой, правит её и слушает саму запись.
- **BREAKING** Раскладка файлов на диске меняется: плоского каталога с именами по
идентификатору не остаётся, файл ложится в раскладку хранилища. Момент перехода
назначает человек.
- **BREAKING** Прежние данные не переносятся. Сервис начинает с чистого каталога
и заводит свою схему сам.
- Файл перестаёт отдаваться чтением с диска и отдаётся ссылкой, которую хранилище
строит по записи.
- Захват задачи воркером становится одним неделимым шагом: две задачи одному
состоянию больше не достаются.
- У задачи появляется число попыток. Задача, исчерпавшая их, переходит в
состояние «мертва»: из выборки исчезает, но остаётся видна владельцу и
возвращается в работу снятием состояния.
- Пауза перед повтором нарастает с номером попытки.
- Сборка перестаёт требовать CGO.
- Появляется секрет, которого не было: пароль владельца от панели. В
конфигурации он не лежит.
## Capabilities
### New Capabilities
- `storage`: где живут запись, её метаданные и её файл; как файл попадает в
хранилище и как отдаётся обратно; что владелец видит и правит в панели; с
каким состоянием сервис поднимается на чистом каталоге.
### Modified Capabilities
- `pipeline`: захват задачи становится неделимым; появляются число попыток,
нарастающая пауза и состояние «мертва» вместо признака ошибки, исключающего
задачу навсегда; описывается срок протухания захвата.
- `intake`: принятая запись уезжает в хранилище, а не в плоский каталог;
требование «имя отправителя в хранилище не попадает» остаётся в силе и в новой
раскладке.
## Impact
- Хранилище задач и файлов целиком: прежний слой запросов, построитель запросов и
механизм миграций уходят вместе с каталогом `migrations/`.
- Договор между ядром и хранилищем: интерфейсы репозиториев задач и файлов.
- Состав полей задачи: прибавляется число попыток, признак ошибки уступает место
состоянию в перечне состояний.
- Ключи конфигурации: путь к базе и путь к каталогу файлов заменяются одним
каталогом данных.
- Приём по HTTP и приём из Telegram — в части того, куда кладётся принятая
запись.
- Сборка образа: набор зависимостей меняется, требование CGO уходит.
- Документы: схема хранилища, инварианты и запреты с путями, модель угроз в части
того, из чего строятся пути.
@@ -0,0 +1,215 @@
# Отчёт ревью — change `pocketbase-storage`
Метка `large`, режим «по графу». Составил проход `review-triage`; файл записал
оркестратор — среда запрещает подагенту писать отчётные `.md`. Раздел «Исход по
находкам» дописан оркестратором после отработки и помечен как таковой.
## Сводка
- **Размер:** крупное. **Сложность:** незнакомое. **Метка:** `large` — максимум
по обеим осям. Триггеры `docs/review.md`: «замена хранилища или переход на
PocketBase — любой её кусок», «смена модели очереди», «изменение, трогающее оба
входа сразу».
- **Гейт:** зелёный, проверен триажем — `task gate`, exit 0, восемь шагов.
- **Особенность прогона:** часть находок первой волны оркестратор починил до
запуска `ops`. Каждую пометку «ПОЧИНЕНО» триаж проверял в коде.
### Сигнал о заниженной метке
Не пришёл. `review-code` возражений не заявил; `review-basics` на этой метке не
запускался. Это одно молчание и одно отсутствие, а не подтверждение двумя
источниками.
### План разметки задачи с исходом по каждой теме
| тема | дом | глубина | кто закрывает | исход |
|---|---|---|---|---|
| requirements | `openspec/specs/` + дельты | разбор | `specs` | **закрыта**, 6 находок |
| autotests | `CLAUDE.md`, «Гейт», «Инварианты» | — | `autotests` | **закрыта**, 3 находки |
| conventions | `docs/conventions/` | разбор | `code` | **закрыта**, 9 находок + 3 за срезом потолка |
| architecture | `docs/architecture.md` + `passport.md` | доказательство | `architecture` | **закрыта**, 7 находок |
| security | `docs/security.md` | доказательство | `adversary` | **закрыта**, 5 находок |
| operations | `docs/architecture.md` «Эксплуатация» + `database.md` | доказательство | `ops` | **закрыта**, 3 находки + 3 замера |
**Темы без отчёта нет.** `basics` не запускался по условию состава при метке
`large` — темы он не уносил.
Отдельно: результатов **ревью дизайна** на вход триажа не подавали. Сошлись ли
ответы автора о трёх формах решения с построенным — на этом прогоне не проверено
никем.
### Счёт находок
36 пунктов от шести проходов → 27 причин после дедупликации → 12 починено до
`ops` (одна частично), 1 снята как неверная, 14 осталось плюс 1 новая, вскрытая
проверкой починок. В отчёте: 3 блокирующих, 3 «исправить сейчас», 6 гипотез,
3 promote.
### Проверка починок на месте
| находка | чем проверено | вердикт |
|---|---|---|
| `MaxSize: 0` = 5 МиБ (specs-1 = code-1 = adversary-2) | `entity.MaxRecordSize = 8 << 30` у поля файла и у тела приёма; тест судит границу `5<<20 + 1` и способен упасть | полна |
| хук панели правил записи конвейера (specs-2 = code-2 = adversary-3 = architecture-1) | `OnRecordUpdateRequest`; два теста, включая обратный | полна в объявленном объёме; остаток — блокер 2 |
| 413 на 32 МиБ и `ReadTimeout` 5 минут | `apis.BodyLimit(entity.MaxRecordSize)` на маршруте, `ReadTimeout = 0` | полна |
| гонка `srv` | буферизованный канал, чтение `select`/`default` | полна |
| `file` без `Required` | `Required: true`, шаг схемы на сервер не уезжал | полна |
| спека утверждала про суффикс имени | прогон: имя в хранилище — ровно заданное, суффикса нет | полна |
| рабочая копия без расширения | `Stage(ext string, …)`, приём передаёт расширение | полна |
| инвариант «колонка в N местах» | возвращён в `CLAUDE.md` с перечнем мест | полна |
| две записи ERROR на один отказ приёма | лог из обработчика снят | полна |
| расхождения с `conventions/database.md` | обе строки на месте | полна |
| **вечная ссылка на чужое аудио через журнал** | прогон: `Open` и `Localize` отдают текст с идентификатором записи, имени файла в нём нет | путь А закрыт; путь Б закрыт наполовину — см. «Стоит исправить», п. 2 |
| пустой держатель в `Save` | комментарий исправлен, ветка оставлена | частично, и этого достаточно |
**Снято как неверное:** замечание `architecture` про ключ объекта в Object
Storage. Оно стояло на посылке о суффиксе имени, которой нет: имя в хранилище и
есть `<uuid><расширение>`, значит утверждение `architecture.md` верно.
## Блокирует мердж
### 1. Образ не собирается: сборщик `golang:1.24`, а модуль требует Go 1.25
- Файл: `Dockerfile:2`, `go.mod:3`
- Severity: major, Confidence: high
- Оракул: сборка в контейнере `golang:1.24-alpine` → `go.mod requires go >= 1.25.0
(running go 1.24.13; GOTOOLCHAIN=local)`; на `golang:1.25-alpine` → успех.
Директива не наша: `pocketbase@v0.39.10/go.mod` объявляет `go 1.25.0`.
- Последствие: `task image` падает — выкладки change нет вовсе. Гейт этого не
показывает: образ он не собирает, а `go build` идёт на хостовом Go.
- Найдено: **никем** — вскрылось проверкой починок (триаж).
- Действие: инлайн
### 2. Правка задачи в панели пропадает при ближайшем сохранении шага
- Файл: `transcript_job_repo.go`, `job_mapping.go`, `panel.go`
- Severity: major, Confidence: high
- Оракул: временный тест — задача с `tg_chat_id = 111` захвачена шагом, правится
через `PATCH /api/collections/transcribe_jobs/records/<id>`, затем шаг
сохраняет снимок. Итог: `expected 999999 / actual 111`.
- Последствие: `Save` сторожил только `acquisition_id`, а запись клала **все**
поля снимком с момента захвата. Окно — до восьми часов. Ни строки в журнале,
ни отказа в панели. Панель ради правки и покупалась.
- Найдено: `ops`.
- Действие: развилка
### 3. Два из трёх шагов конвейера переписаны и не выполнены ни одним тестом
- Файл: `internal/service/transcribe.go` — `transcribeJob`, `checkTranscribeJob`,
`completeJob`
- Severity: major, Confidence: high
- Оракул: покрытие с `-coverpkg=./...` — 0.0% у всех трёх и у обоих
`FindAndRun*`; `git diff --stat` по файлу — 268 вставок, 192 удаления.
- Последствие: путь «отдать запись на распознавание → дождаться операции →
ответить пользователю» прошёл замену хранилища без единого выполнения.
- Найдено: `autotests`.
- Действие: инлайн
## Стоит исправить сейчас
### 1. Документы обещают, что рабочую копию убирает хранилище; убирает вызывающий
- Severity: minor, Confidence: high. Найдено: `architecture`. Действие: развилка.
- `architecture.md`, `design.md` и дельта-спека `storage` требуют, чтобы уборку
делало хранилище. В коде копию закрывает вызывающий тремя `defer`. Дефекта в
коде нет — расхождение в записи, которая уедет в архив.
### 2. Отказ заливки в Object Storage больше не отличим от любого другого
- Severity: minor, Confidence: high. Найдено: **никем** — вскрылось проверкой
полноты починки. Действие: инлайн.
- Починка приватности оборвала цепочку `%w` целиком: отозванные ключи,
исчезнувший бакет и отказ сети дают одну строку.
### 3. Входящие HTTP-запросы исчезли из журнала процесса
- Severity: minor, Confidence: high. Найдено: `code`. Действие: развилка.
- `sloggin` ушёл вместе с gin; `activityLogger` PocketBase пишет в свою таблицу.
Строка «*Расхождение:*» в `logging.md` указывает на удалённую библиотеку.
## Гипотезы без доказательства
1. Ответ SpeechKit об отказе операции может нести S3-URI и уехать в журнал и в
`error_text`. Оракул требует настоящего отказа Yandex — запрещено `CLAUDE.md`.
2. Имя файла в хранилище принимает почти любой хвост имени отправителя. Выхода за
каталог **нет** (15 враждебных имён). Станет находкой с появлением экрана
списка записей.
3. `entity.File.FileName` несёт два смысла — имя в хранилище и ключ объекта.
Сегодня они совпадают дословно.
4. Отмена контекста структурно невозможна: `RunInTransaction`/`FindRecordById`
контекста не принимают. Замер: под внешней блокировкой захват держится 9.53 с
при `busy_timeout=10000`. Уточнение уже объявленного долга.
5. Таймаутов у Telegram, S3 и SpeechKit по-прежнему нет. Задача заведена.
6. Приём пишет тело на диск дважды. Не замерено; нагрузка объявлена вне модели.
Ниже потолка названы и не потеряны: `LostAcquisitionError` типом там, где
конвенция просит sentinel; форма обёрток `"failed to <действие>: %w"`; уровень
`ERROR` с инкрементом метрики на потерянном захвате против `WARN` конвенции;
панель как третий вход в таблицу задач.
## Promote candidates
1. **`govulncheck ./...` шагом гейта.** Сегодня его нет ни в гейте, ни в списке
«чего в гейте намеренно нет». Достижимых уязвимостей две, унаследованных:
`GO-2026-6061` (grpc), `GO-2026-5764` (aws eventstream, s3).
2. **Сверка версии Go в `Dockerfile` с директивой `go` в `go.mod`.** Блокер 1
прошёл весь конвейер молча именно потому, что образ никто не собирает.
3. **Покрытие изменённых строк.** Блокер 3 — второй случай подряд, когда его
ловят руками.
## Границы покрытия
- Запускались: `specs`, `code`, `architecture`, `adversary`, `autotests` — по
коду **до** починок первой волны; `ops` — по коду **после**. Находки пяти
первых перепроверены триажем поимённо.
- `basics` не запускался: при метке `large` темы разобраны именными проходами,
своих тем проекта нет.
- Независимой реализации для сравнения не строил никто — прохода нет в конвейере.
- Поимённая сверка с руководствами по стилю Go не задавалась ни одним проходом.
Для change, который впервые в проекте пишет на PocketBase, это самый дорогой
пробел.
- **Блоки `Coverage of this pass` до триажа дошли не от всех проходов.** О своих
потолках сообщил только `code` (конвенционная половина, 4, за срезом три
находки — перенесены). Остальные не сообщили; это находка о прогоне.
- Решения проекта (`docs/adr/`) и записанные наблюдения (`docs/research/`) прогон
не открывает — процессные документы. Расхождение change с записанным решением
ловит не ревью, а сверка документации (`av-dev-docs:healthcheck`).
- Не проверит ни один проход: поведение SpeechKit и Object Storage под нагрузкой
и на границах; реальный профиль нагрузки; стойкость `ffmpeg` к вредоносному
входу.
- Перестали проверять сознательно: разбор вывода настоящего `ffprobe` — решение и
цена в `ADR-2026-08-11-stub-adapters-in-tests.md`.
- Каких документов не хватило: `docs/conventions/web-ui.md` описывает будущую
SPA, а не панель — судить панель против конвенции было нечем; `docs/research/`
по весу шестичасовой записи пуст, оценки читались как оценки.
## Исход по находкам
*Раздел дописан оркестратором после отработки урожая; в отчёте триажа его нет.*
**Починено:**
- блокер 1 — `Dockerfile` переведён на `golang:1.25-alpine`, строки про Go 1.24 в
`CLAUDE.md` и `README.md` исправлены; сборка в контейнере проверена;
- блокер 2 — запись шага разделена: `applyOwnedByPipeline` кладёт только поля
конвейера, `applyToRecord` целиком остаётся заведению. Заведён тест
`TestSave_KeepsOwnerEditMadeWhileStepHeldTheJob`; норма дописана в дельта-спеку
`pipeline` со сценарием;
- блокер 3 — заведён управляемый двойник распознавателя и восемь проверок
(`internal/service/recognition_test.go`). Покрытие: `transcribeJob` 0 → 67.6%,
`checkTranscribeJob` 0 → 72.4%, `completeJob` 0 → 100%, оба `FindAndRun*` → 100%;
- «стоит исправить» 2 — класс отказа SDK сохраняется через `smithy.APIError`,
адрес объекта — нет;
- «стоит исправить» 3 — журнал входящих запросов вернулся своим слоем в `main.go`
с пятью полями конвенции, `/health` и `/metrics` на `DEBUG`; расхождение в
`logging.md` снято.
**Решено развилкой, вариант назван человеку на чекпоинте:**
- «стоит исправить» 1 — норму привели к коду: хранилище даёт единственный способ
убрать копию, зовёт его шаг. Замкнуть уборку на репозиторий мешает конвертация:
ей нужны две копии разом. Цена названа — норма держится проверками, и проверки
заведены на приём и на шаг конвертации.
**Оставлено, передано урожаем:** все шесть гипотез и четыре находки ниже потолка;
три кандидата в promote.
@@ -0,0 +1,166 @@
## MODIFIED Requirements
### Requirement: Приём записи по HTTP
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
телом `multipart/form-data` и полем `audio`. Принятая запись MUST быть сохранена
и получить заведённую под неё задачу расшифровки в состоянии `created`; ответ
MUST нести идентификатор задачи полем `job_id` и её состояние полем `status`.
Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым,
и переименование поля ломает внешнюю программу молча.
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
пригодность содержимого узнаёт у источника метаданных.
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`.
#### Scenario: Запись принята
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
со значением `created`
- **AND** содержимое записи целиком лежит в хранилище одним файлом
#### Scenario: Поля с записью нет
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
- **AND** ни файла, ни задачи не заводится
#### Scenario: Размеру записи приём не судья
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт запись нулевой длины
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
### Requirement: Имя файла в хранилище
Сервис SHALL сохранять принятую запись под собственным именем — идентификатором,
к которому приписано расширение из имени файла отправителя. Имя, данное
отправителем, MUST не попадать в хранилище: оно приходит извне и содержимым
своим приёму не подконтрольно.
Расширения в присланном имени нет — сервис MUST подставить `.audio`, чтобы у
файла в хранилище расширение было всегда.
Требование переживает смену раскладки. Умолчание хранилища, строящее имя из
имени отправителя, MUST не применяться: имя отправителя в журнал не пишется по
инварианту приватности, а изъятие из него кончается расширением — хвостом после
последней точки.
#### Scenario: Расширение взято из имени отправителя
- **WHEN** программа шлёт запись с именем `test.mp3`
- **THEN** имя файла в хранилище оканчивается на `.mp3`
#### Scenario: Имени без расширения назначено своё
- **WHEN** программа шлёт запись с именем `test` без расширения
- **THEN** имя файла в хранилище оканчивается на `.audio`
#### Scenario: Имя отправителя в хранилище не попало
- **WHEN** программа шлёт запись с именем `секретное-слово.mp3`
- **THEN** имя файла в хранилище не содержит `секретное-слово`
- **AND** путь к этому файлу не содержит его тоже
### Requirement: Имя файла, данное отправителем, не попадает в журнал
Приём SHALL не писать имя файла, данное отправителем, ни в одну свою журнальную
запись — ни на успешном пути, ни на пути отказа, где имя могло бы приехать
текстом ошибки. Имя приходит извне вместе с записью и принадлежит содержимому
личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи,
откуда строку не убрать.
Расширение, взятое из этого имени, в журнале остаётся собственным полем: по нему
прослеживается путь записи. Что именно попадает в журнал ради прослеживаемости,
нормирует требование ниже; наружу расширение выходит только приведённым к
известному виду — этому отдано отдельное требование.
Сценарии судят приём по HTTP, потому что имя, данное отправителем, доходит до
сервиса только оттуда: из Telegram приходит путь, выданный самим Telegram, а не
имя человека. Правка при этом ложится на общий шаг заведения задачи, через
который идут оба входа, поэтому своей нормы приём из Telegram здесь не получает —
её напишет задача, которая тронет его поведение.
#### Scenario: Имя записи не видно в журнале принятой записи
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
опознаваемую строку при обычном расширении `.mp3`
- **THEN** ни одна журнальная запись приёма этой строки не содержит
- **AND** расширение `.mp3` в журнале допустимо
#### Scenario: Имя записи не видно в журнале при отказе приёма
- **GIVEN** источник метаданных не может прочитать запись
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
опознаваемую строку
- **THEN** ни одна журнальная запись приёма, включая запись об ошибке, этой
строки не содержит
### Requirement: Журнал приёма прослеживает запись
Приём SHALL писать в журнал идентификатор заведённого файла, расширение принятой
записи и её размер в байтах. По ним путь записи собирается отбором по журналу, и
удаление имени отправителя прослеживаемости не отнимает.
Расширение засчитывается собственным полем журнальной строки. Имя, под которым
файл лёг в хранилище, приём MUST в журнал не писать: это имя — последняя часть
ссылки на скачивание, и записанное вместе с идентификатором записи оно собирает
ссылку целиком. Норму держит capability `storage`.
#### Scenario: Идентификатор, расширение и размер на месте
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью
- **THEN** журнал приёма несёт идентификатор заведённого файла, расширение
принятой записи и её размер в байтах
#### Scenario: Имени файла в хранилище в журнале нет
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью
- **THEN** имени, под которым файл лёг в хранилище, в журнале приёма нет
### Requirement: Метка метрики несёт только известное расширение
Сервис SHALL приводить расширение принятой записи к известному виду прежде, чем
употребить его меткой метрики: расширение приводится к нижнему регистру и
сверяется с закрытым перечнем; совпавшее идёт приведённым, всякое другое MUST
заменяться единым значением `other`. Перечень — `mp3`, `wav`, `ogg`, `oga`,
`opus`, `flac`, `m4a`, `aac`, `wma`, `mp4`, `mkv`, `mov`, `avi`, `webm`, плюс
`audio`: последнее не формат, а собственное умолчание сервиса на случай имени
без расширения, и различать его от чужого хвоста метка обязана.
Страница метрик отдаётся без проверки отправителя, поэтому метка — поверхность
пошире журнала: её читает кто угодно. Тем же ограничением снимается и рост числа
временных рядов, которым иначе распоряжается анонимный отправитель.
Требование намеренно шире приёма: под него подпадает и метка шага конвертации.
Когда конвертацию нормируют своей capability, обязанность переезжает туда вместе
с ней.
Имя файла в хранилище это требование не трогает: там расширение остаётся тем,
каким пришло, — это уже нормировано требованием «Имя файла в хранилище».
Настоящий формат записи, попавшей в `other`, остаётся видимым в журнале: значение
`other` в метке означает «расширение не из перечня», а само оно стоит полем
журнальной строки приёма и полем формата строки конвертации.
#### Scenario: Незнакомое расширение наружу не выходит
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт запись с именем, чей хвост после последней точки не
принадлежит перечню known-форматов
- **THEN** метка метрики принимает значение `other`
- **AND** имя файла в хранилище сохраняет пришедшее расширение
#### Scenario: Известное расширение идёт как есть
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт запись с именем `sample.MP3`
- **THEN** метка метрики принимает значение `mp3`
@@ -0,0 +1,200 @@
## Purpose
Конвейер расшифровки: как задача движется по состояниям, что делает воркер,
когда работы нет, и что считается отказом шага.
Описаны пустой прогон воркера, захват задачи и срок его протухания, число
попыток и выбывание задачи из очереди, пауза перед повтором. Сознательно не
описаны переходы состояний и сама цепочка `created → converted → transcribe →
done | failed`, отмена контекста посреди шага, освобождение ресурсов внешних
клиентов. Это не значит, что такого поведения нет: оно живёт в коде, а
требования на него не написаны, потому что требование без проверки —
предположение, а не норма. Первая задача, которая трогает любое из
перечисленного, дописывает его сюда.
## ADDED Requirements
### Requirement: Захват задачи неделим
Захват задачи воркером SHALL быть одним неделимым шагом хранилища: выбор
подходящей задачи и пометка её захваченной MUST происходить вместе, и захваченная
задача MUST возвращаться тем же шагом.
Одна и та же задача MUST доставаться ровно одному захватившему. Двум вызывающим,
пришедшим за одним состоянием одновременно, запись MUST достаться одному, а
второй MUST получить признак «работы в этом состоянии нет».
Порядок выборки MUST быть определён однозначно: сравнения по неуникальному
значению для этого мало, и к нему MUST добавляться ключ записи. Иначе порядок
обработки невоспроизводим, а проверка, опирающаяся на «следующую» задачу,
зелена через раз.
Требование стоит на инварианте проекта «Принятая запись не теряется молча»:
захват, разделённый на два шага, отдаёт одну запись двум воркерам, и работа
одного из них теряется без следа.
Признак «работы нет» этим требованием не переопределяется — его нормирует
требование «Пустой прогон воркера — не отказ».
#### Scenario: За задачей пришли трое разом
- **GIVEN** в опрашиваемом состоянии лежит ровно одна задача
- **WHEN** три захвата этого состояния идут одновременно
- **THEN** запись получает ровно один из них
- **AND** двое остальных получают признак «работы в этом состоянии нет»
#### Scenario: Захваченная задача не выдаётся второй раз
- **GIVEN** задача захвачена и срок захвата не истёк
- **WHEN** за тем же состоянием приходит следующий захват
- **THEN** эта задача ему не выдаётся
### Requirement: Результат пишет только держатель захвата
Шаг конвейера SHALL записывать свой результат только тогда, когда захват задачи
всё ещё принадлежит ему. Запись MUST быть условна по признаку захвата, а шаг,
чей захват за время работы достался другому, MUST завершиться без записи
результата и без ответа отправителю.
Требование закрывает то, чего неделимость захвата не закрывает: захват протухает
не только у мёртвого воркера, но и у живого — шаг, идущий дольше своего срока,
теряет задачу, продолжая работать. Без этого условия два воркера пишут в одну
задачу по очереди, счётчик попыток сбрасывает тот, кто уже не владелец, а
отправитель получает два ответа на одну запись.
Шаг MUST записывать только те поля, которыми распоряжается сам. Задачу он держит
снимком с момента захвата и до записи — это часы, — и безусловная запись снимка
стёрла бы всё, что владелец правил в панели за это время: молча, без строки в
журнале и без отказа в панели. Владелец увидел бы успешное сохранение и был бы
уверен, что правка на месте.
#### Scenario: Правка владельца пережила сохранение шага
- **GIVEN** шаг держит захваченную задачу
- **AND** владелец за это время изменил в панели поле, которого шаг не касается
- **WHEN** шаг записывает свой результат
- **THEN** результат шага записан
- **AND** правка владельца на месте
#### Scenario: Захват ушёл под работающим шагом
- **GIVEN** шаг работает над захваченной задачей
- **AND** за это время та же задача досталась другому захвату
- **WHEN** первый шаг доходит до записи результата
- **THEN** результат не записывается
- **AND** отправителю ничего не отправляется
### Requirement: Брошенная задача возвращается в работу
Задача, захваченная и брошенная на середине, SHALL доставаться снова по
истечении срока захвата. Срок MUST считаться от времени захвата, а истёкший
захват MUST не мешать выдать задачу следующему.
Срок задаётся шагом конвейера и MUST быть не меньше того времени, которое этот
шаг может занять на самом длинном допустимом входе. Срок короче делает
протухание штатным событием живого шага, а не признаком беды.
Все значения времени, по которым идёт этот отбор, MUST записываться и сравниваться
в одном виде — том же, в каком хранилище пишет собственные времена записи.
Сравнение идёт побайтово, и вид, разошедшийся хоть разделителем, обращает
условие в постоянную истину или постоянную ложь, причём молча.
#### Scenario: Захват протух
- **GIVEN** задача захвачена, а время захвата отстоит дальше срока
- **WHEN** за её состоянием приходит захват
- **THEN** задача выдаётся ему
#### Scenario: Срок сравнивается с временем, записанным хранилищем
- **GIVEN** задача захвачена, и время захвата записано в том же виде, в каком
хранилище пишет время изменения записи
- **WHEN** за её состоянием приходит захват до истечения срока
- **THEN** задача ему не выдаётся
### Requirement: Число попыток и состояние «мертва»
У задачи 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** следующий захват выдаёт её снова
### Requirement: Пауза перед повтором нарастает
Перед повтором **отказавшей** задачи сервис SHALL выдерживать паузу, и пауза
MUST расти с числом её попыток до объявленного потолка. Задача MUST не
выдаваться захвату, пока пауза не кончилась.
Ожидание чужой операции этой паузой MUST не выражаться. Шаг, увидевший, что
внешняя операция ещё идёт, отработал без отказа: он назначает **свою** задержку
опроса, заданную числом, и попытки при этом не тратит. Пауза, выведенная из
числа попыток, на таком шаге вырождается в наименьшее своё значение и учащает
опрос внешнего сервиса во столько раз, во сколько задержка опроса длиннее секунды.
#### Scenario: Отказавшая задача ждёт
- **GIVEN** задача отказала на шаге конвейера
- **WHEN** захват приходит раньше конца её паузы
- **THEN** задача ему не выдаётся
#### Scenario: Вторая пауза длиннее первой
- **GIVEN** задача отказала дважды подряд
- **WHEN** сравнивают паузу после второго отказа с паузой после первого
- **THEN** вторая длиннее
#### Scenario: Ожидание операции не учащается и не тратит попыток
- **GIVEN** внешняя операция распознавания ещё идёт
- **WHEN** шаг проверки отрабатывает подряд несколько раз
- **THEN** задержка до следующей проверки каждый раз одна и та же
- **AND** число попыток задачи не растёт
@@ -0,0 +1,240 @@
## Purpose
Где живут запись, её метаданные и её файл; как файл попадает в хранилище и как
отдаётся обратно; что видит и правит владелец сервиса; с каким состоянием сервис
поднимается на чистом каталоге.
Сознательно не описаны удаление записей и файлов, срок их хранения, резервное
копирование и вход пользователей: первое и второе решены хранить бессрочно,
третье принадлежит серверу, четвёртое — задаче про вход. Первая задача, которая
тронет любое из перечисленного, дописывает его сюда.
## ADDED Requirements
### Requirement: Сервис поднимается на чистом каталоге данных
Сервис SHALL приводить хранилище в рабочий вид сам: на пустом каталоге данных он
MUST завести свою схему и принимать записи обоими входами без единого ручного
шага до первого запуска.
Прежние данные не переносятся. Каталог, оставшийся от прежней раскладки, MUST не
читаться и не считаться источником: сервис начинает с чистого листа, и это
решение задачи, а не следствие отказа.
Схема MUST заводиться версионированными шагами, а применённый шаг MUST не
переписываться — только новым шагом. Иначе повторный запуск на уже заведённом
каталоге разошёлся бы с первым молча.
Каталог данных у сервиса MUST быть один: база и файлы записей лежат под ним
вместе, и второго пути к ним не заводится.
#### Scenario: Первый запуск на пустом каталоге
- **GIVEN** каталог данных пуст
- **WHEN** сервис запускается
- **THEN** он заводит своё хранилище и продолжает работу
- **AND** принятая следом запись доходит до состояния `done`
#### Scenario: Повторный запуск на заведённом каталоге
- **GIVEN** сервис уже запускался на этом каталоге и завёл хранилище
- **WHEN** он запускается снова
- **THEN** он не заводит схему второй раз и не теряет прежние записи
### Requirement: Файл записи живёт в хранилище
Сервис SHALL держать файл записи в хранилище, а не отдельным каталогом рядом с
ним. Файл MUST попадать туда вместе с записью, которой принадлежит, и MUST
адресоваться этой записью, а не путём на диске.
Раскладку файлов на диске выбирает хранилище. Собственного плоского каталога
записей у сервиса MUST не оставаться: файл, лежащий мимо хранилища, не попадёт
ни в панель владельца, ни в резервную копию, а ради этих двух вещей перевод и
делается.
Содержимое записи MUST не читаться в память целиком ни при укладке в хранилище,
ни при чтении из него: расчётный потолок записи — шесть часов, и такая запись в
память не помещается.
**Потолок размера записи MUST быть задан числом, выведенным из этого расчётного
потолка**, и задан он MUST быть везде, где иначе действует чужое умолчание: и у
поля файла в хранилище, и у тела запроса приёма. Умолчания здесь не «без
предела», а величины на два-три порядка меньше нужного, и оставленные как есть
они отвергают штатную запись сервиса — приём отказывает, а уже принятая запись
исчерпывает попытки на шаге конвертации.
Отказ по этому потолку MUST быть виден отправителю ответом, а не молчанием.
Шаги, которым нужен файл именем на диске — конвертация и чтение метаданных
отдают его внешней программе, — MUST получать рабочую копию **одним общим
способом**, и у этого способа MUST быть единственный способ её убрать. Уборку
зовёт шаг, и звать её он MUST на любом исходе, включая отказ. Заводить копию по
месту шагам MUST не приходиться: иначе обязанность прибрать переписывается
столько раз, сколько шагов, а забытая копия — это шестичасовая запись,
оставшаяся во временном каталоге, и узнать о ней неоткуда.
#### Scenario: Принятая запись легла в хранилище
- **WHEN** запись принята любым входом
- **THEN** её файл лежит в хранилище и связан со своей записью
- **AND** отдельного каталога записей рядом с хранилищем не появляется
#### Scenario: Запись длиннее чужого умолчания принимается
- **WHEN** в хранилище кладут запись длиннее умолчания, действующего у поля файла
- **THEN** она ложится в хранилище, а не отвергается
#### Scenario: Шаг конвейера берёт файл по записи
- **GIVEN** запись принята и её файл лежит в хранилище
- **WHEN** шаг конвейера берётся за эту запись
- **THEN** он получает файл по самой записи, а не по пути на диске
#### Scenario: Рабочая копия убрана после отказа шага
- **GIVEN** шагу выдана рабочая копия файла
- **WHEN** шаг завершается отказом
- **THEN** рабочей копии во временном каталоге не остаётся
### Requirement: Файл отдаётся ссылкой
Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой
записи. Отданный файл MUST совпадать с принятым по длине.
Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом.
**Ссылка сама по себе и есть право пройти по ней**, и потому она MUST не попадать
ни в журнал, ни в метку метрики, ни в ответ отправителю. Имя, под которым файл
лёг в хранилище, из журнала выводимо быть не должно: журнал уезжает в собранные
логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы
бессрочно.
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла
целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и
другое кончается в журнале и собирает ссылку не хуже успешного пути.
Что именно журнал приёма пишет ради прослеживаемости, нормирует capability
`intake`.
#### Scenario: Файл забирают по ссылке
- **GIVEN** запись принята и её файл лежит в хранилище
- **WHEN** ссылку на файл запрашивают
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
#### Scenario: Ссылка ведёт в никуда
- **WHEN** запрашивают ссылку на запись, которой нет
- **THEN** приходит отказ, а не пустой ответ
#### Scenario: По журналу ссылку не собрать
- **GIVEN** запись принята и прошла конвейер
- **WHEN** читают журнал сервиса целиком
- **THEN** имени, под которым файл лёг в хранилище, в нём нет
#### Scenario: Отказ чтения файла не называет его ключ
- **GIVEN** файл записи не читается из хранилища
- **WHEN** шаг конвейера берётся за эту запись и отказывает
- **THEN** отказ называет запись её идентификатором и не несёт имени файла
### Requirement: Наружу хранилище отдаёт только то, что заказано
Сервис SHALL держать закрытыми собственные разделы хранилища, которые тот
публикует тем же портом. Запрос без прав владельца MUST получать отказ на
перечисление и чтение записей коллекций, на служебные разделы хранилища —
журналы запросов, резервные копии, настройки, расписание — и на правку чего бы
то ни было.
Требование заводится потому, что порт опубликован в интернет, а вместе с
переводом наружу выходит поверхность, которой у сервиса не было. Что API сервиса
сегодня открыт всякому — известно и записано моделью угроз; новая поверхность под
это знание не подпадает и закрывается здесь.
Правило доступа, оставленное пустым, значит «только владелец панели». Именно
пустым оно MUST и оставаться: непустое правило, поставленное будущей правкой
схемы, открыло бы перечисление всех записей анонимному запросу и не нарушило бы
при этом ни одного другого требования.
#### Scenario: Аноним перечисляет записи
- **WHEN** запрос без прав владельца просит список записей коллекции задач
- **THEN** приходит отказ
#### Scenario: Аноним читает служебный раздел
- **WHEN** запрос без прав владельца просит журнал запросов или список резервных
копий хранилища
- **THEN** приходит отказ
### Requirement: Владелец видит записи в панели
Сервис SHALL давать владельцу панель, где задача видна строкой, отбирается по
своему идентификатору и правится, а её файл слушается и скачивается.
Панель MUST отдаваться тем же сервисом по своему адресу и MUST не требовать
второго процесса.
Панель — вход в задачу наравне с конвейером, а не окно просмотра, и правка
состояния задачи в ней MUST подчиняться тем же правилам перехода, что и правка
из кода: служебные поля прошлого состояния — признак захвата, время захвата,
пауза, число попыток — MUST очищаться. Иначе владелец, вернувший мёртвую задачу в
работу, получит задачу, которая не выдаётся захвату до конца прежнего срока и
умирает от первого же отказа, — и не узнает об этом.
Задача, заведённая в панели руками, MUST не уносить сервис: поля, без которых
шаг конвейера не может работать, MUST быть обязательными в самой схеме, а
перечень состояний — закрытым.
Панель разграничению доступа сервиса не подчиняется: вошедший в неё видит все
записи, все файлы и всех пользователей разом. Закрывает её контур выкладки, а не
сервис — это записано моделью угроз проекта.
#### Scenario: Принятая запись видна владельцу
- **GIVEN** запись принята и её задача заведена
- **WHEN** владелец отбирает задачи по идентификатору принятой
- **THEN** он видит её строкой со своим состоянием
- **AND** файл этой записи скачивается из той же строки
#### Scenario: Мёртвую задачу вернули в работу правкой в панели
- **GIVEN** задача в состоянии «мертва» с исчерпанными попытками и признаком
прежнего захвата
- **WHEN** владелец меняет её состояние на рабочее
- **THEN** признак захвата, время захвата, пауза и число попыток очищены
- **AND** ближайший захват выдаёт задачу
### Requirement: Пароль владельца от панели не лежит в конфигурации
Сервис SHALL не заводить в конфигурации ключа под пароль владельца от панели.
Пароль MUST задаваться самим владельцем, а хранилище MUST держать только его
отпечаток.
Требование стоит на инварианте проекта «Секрет не покидает конфиг» с другой
стороны: секрет, которого в конфигурации нет, не утекает вместе с ней и не
уезжает в выкладку третьим путём. Пароль от панели открывает все записи и все
файлы разом — это самое чувствительное, что есть у сервиса.
Приглашение завести владельца сервис MUST печатать только пока владельца нет, и
оно MUST истекать по времени. Приглашение равносильно паролю от панели, а
печатается оно в журнал контейнера, откуда строку не убрать: бессрочное отдало бы
панель всякому читателю логов навсегда.
Пока владелец пароля не задал, сервис MUST работать обоими входами: панель без
владельца не мешает принимать записи.
#### Scenario: Владелец пароля ещё не задал
- **GIVEN** каталог данных пуст и владелец панели не заведён
- **WHEN** сервис запускается
- **THEN** он принимает записи обоими входами
- **AND** ни один ключ конфигурации не несёт пароля от панели
#### Scenario: Владелец заведён, приглашение больше не печатается
- **GIVEN** владелец панели заведён
- **WHEN** сервис запускается снова
- **THEN** приглашения завести владельца в журнале нет
@@ -0,0 +1,196 @@
## Критерии приёмки
### От постановки
Дословно из записи задачи `pocketbase-storage`. Файл задачи закрытие удалит —
критерии обязаны его пережить. Одно уточнение внесено ревью дизайна и отмечено
курсивом: прогон на реальных ключах Yandex запрещён проектом, поэтому
распознаватель в прогоне подставной.
- Сервис поднимается на чистом каталоге данных, накатывает свою схему сам и
принимает запись обоими входами. Оракул — запуск на пустом `data/` и прогон
записи из Telegram и через `POST /api/audio` до состояния `done`
*с подставным распознавателем `internal/adapter/recognizer/memory.go`*.
- Захват задачи воркером идёт одним запросом и не выдаёт одну запись двум
вызывающим. Оракул — тест на трёх параллельных вызовах захвата по одному
состоянию: ровно один получает запись.
- Задача, брошенная на середине, достаётся снова по истечении срока захвата, а
падающая всегда — уходит в «мертва» и из выборки исчезает. Оракулы — тест с
проставленным задним числом `acquire_time` и тест с шагом, падающим на каждой
попытке: после заданного их числа задача не выдаётся, а её состояние видно
отбором.
- Принятая запись видна в панели строкой и скачивается по ссылке
`/api/files/...` тем же файлом. Оракулы — прогон записи через
`POST /api/audio` на пустом каталоге, затем поиск её строки в коллекции задач
на `/_/` по идентификатору и запрос `/api/files/...` за тем же файлом: длина
совпадает с загруженной.
- `docs/database.md` описывает новую схему, а старые упоминания goose и goqu из
документов канона убраны. Оракул — `task gate`, шаг `docs.py check`.
### От ревью дизайна (рубрика прохода `rubric`)
Свойства узла, порождённые до чтения артефактов. Пункты 1, 11 и 12 закрыты
дельта-спеками, 7 неприменим по объявленному Non-Goal, остальные проверяются
поимённо.
- **Захват атомарен.** Критерий успеха — сам факт возврата записи, а не
последующее чтение; ноль записей отличается от отказа. Оракул — 7.2.
- **Протухший захват не создаёт двух живых исполнителей.** Срок захвата назван
числом и не меньше худшего времени шага; запись результата условна по
владельцу захвата. Оракулы — 7.3 и 7.8.
- **У каждого пути выбывания назван актор перехода.** Отказ шага, брошенная
задача, гибель процесса — все три доходят до «мертва». Оракулы — 7.4 и 7.7.
- **Узел читает состояние, которое сам же меняет.** Порядок выборки
детерминирован и имеет тай-брейк по ключу; значения, выведенные из счётчика,
определены при любом порядке параллельных операций. Оракулы — 7.2 и 7.9.
- **Идемпотентность повтора.** Падение между «работа сделана» и «результат
записан» не создаёт при повторе второго файла и второй записи; ссылка на файл
переставляется только после того, как запись о новом файле существует. Оракул
— 7.10.
- **Атомарность записи файла и уборка временного.** Обрыв и отмена не оставляют
читаемого огрызка; рабочая копия убирается на всех ветках выхода. Оракул —
7.11.
- **Ссылка на файл: кто вправе по ней пройти.** Названы право и неугадываемость;
ссылка не оседает там, где её прочтут посторонние. Оракулы — 7.12 и 9.4.
- **Границы транзакции и отмена контекста.** Частичный переход невозможен либо
назван и компенсирован порядком операций. Оракул — 7.10.
- **Источник времени и идентификаторов един.** Один формат и одна зона у всех,
кто колонку времени пишет и сравнивает, включая запросы мимо слоя записей.
Оракул — 7.9.
## 1. Зависимости и каркас хранилища
- [x] 1.1 Добавить `github.com/pocketbase/pocketbase` v0.39.10, убрать
`mattn/go-sqlite3`, `doug-martin/goqu/v9`, `pressly/goose/v3`, `gin-gonic/gin`,
`samber/slog-gin`; `go mod tidy` проходит, `CGO_ENABLED=0 go build ./...`
собирается
- [x] 1.2 Завести пакет хранилища: создание приложения PocketBase из
конфигурации, `Bootstrap()`, доступ к нему для репозиториев
- [x] 1.3 Заменить ключи `[database] path` и `[storage] path` одним ключом
каталога данных (имя выбрано человеком на чекпоинте) в `internal/config` и в
`config.dist.toml`
- [x] 1.4 Удалить каталог `migrations/*.sql`, вшивание его в бинарник и функцию
`RunMigrations`
## 2. Схема коллекций
- [x] 2.1 Написать миграцию, заводящую коллекцию `files` с полями `file`,
`location`, `object_key`, `size`
- [x] 2.2 Написать миграцию, заводящую коллекцию `transcribe_jobs` с полями
сегодняшней таблицы, без `is_error`, плюс `attempts`, плюс значение `dead` у
`state`
- [x] 2.3 Задать в схеме ограничения, которые сегодня держит компилятор: ссылка
на файл обязательна, `state` — закрытый перечень, `attempts` неотрицательно
- [x] 2.4 Оставить правила доступа обеих коллекций пустыми и проверить, что
анонимный запрос к записям получает отказ
- [x] 2.5 Проверить: на пустом каталоге сервис заводит обе коллекции, на
заведённом — не заводит второй раз
## 3. Репозитории
- [x] 3.1 Переписать `FileRepository` на записи коллекции: укладка файла потоком
из временного файла, собственное имя вида `<идентификатор><расширение>`
- [x] 3.2 Дать `FileRepository` единый способ выдать рабочую копию файла на
диске шагу, которому нужен путь, с уборкой копии на любом исходе
- [x] 3.3 Переписать `TranscriptJobRepository` на записи коллекции: `Create`,
`Save`, `GetByID`
- [x] 3.4 Написать `FindAndAcquire` одним запросом с `RETURNING`: рост
`attempts`, отбор по состоянию, паузе и сроку захвата, `ORDER BY` с
тай-брейком по ключу
- [x] 3.5 Все времена очереди писать и сравнивать в том же виде, в каком
хранилище пишет `created`/`updated` (`2006-01-02 15:04:05.000Z`, UTC)
- [x] 3.6 Сделать сохранение результата условным по признаку захвата: чужой
захват — отказ сохранения, отличимый от прочих
- [x] 3.7 Обновить `internal/contract` под новые обязанности репозиториев;
построения ссылки на файл в контракт не заводить
- [x] 3.8 Удалить пакет `internal/adapter/repo/sqlite`
## 4. Очередь: попытки, «мертва», пауза
- [x] 4.1 Убрать `IsError` из `entity.TranscribeJob`, завести `Attempts` и
состояние `StateDead`
- [x] 4.2 Обнулять `Attempts` на каждом шаге, завершившемся без отказа
- [x] 4.3 Переводить в `dead` задачу, захваченную с числом попыток сверх предела:
перевод делает захвативший, до работы шага
- [x] 4.4 Сообщать отправителю о переходе в `dead` тем же путём, каким сообщается
отказ шага
- [x] 4.5 Завести нарастающую паузу перед повтором отказавшей задачи с потолком
- [x] 4.6 Оставить задержку опроса операции распознавания числом, отдельно от
паузы повтора
- [x] 4.7 Завершать шаг без записи результата и без ответа отправителю, когда
захват за время работы достался другому
## 5. HTTP и панель
- [x] 5.1 Перевести `POST /api/audio` и `GET /api/status/:id` на роутер
PocketBase, сохранив имена полей ответа и коды
- [x] 5.2 Перевести `GET /health` и `GET /metrics` туда же
- [x] 5.3 Переписать `main.go`: `apis.Serve` вместо gin, мягкая остановка и
таймауты из конфигурации сохраняются
- [x] 5.4 Повесить хук на правку записи задачи: смена состояния чистит признак
захвата, время захвата, паузу и число попыток
- [x] 5.5 Убедиться, что панель отвечает по `/_/`, приглашение завести владельца
печатается при первом запуске и не печатается после того, как владелец заведён
## 6. Приём и конвейер
- [x] 6.1 Перевести приём (`createTranscribeJob`) на укладку записи в хранилище
через временный файл, с уборкой за собой
- [x] 6.2 Перевести шаги конвертации и распознавания на рабочую копию из 3.2
- [x] 6.3 Писать в журнал расширение записи собственным полем, а имени файла —
ни заданного сервисом, ни того, под которым он лёг в хранилище: имя вместе с
идентификатором записи собирает ссылку на скачивание
- [x] 6.4 Проверить, что имя отправителя не попадает ни в имя файла в хранилище,
ни в журнал
## 7. Проверки
- [x] 7.1 Переписать проверки приёма по HTTP под новый обработчик, сохранив все
сценарии спеки `intake`, включая запрет имени отправителя в журнале
- [x] 7.2 Тест захвата: три параллельных вызова по одному состоянию — запись
получает ровно один
- [x] 7.3 Тест протухшего захвата: `acquire_time` задним числом — задача выдаётся
снова
- [x] 7.4 Тест предела попыток: после заданного числа отказов задача в `dead`,
захвату не выдаётся, видна отбором по состоянию, а отправитель получил
сообщение
- [x] 7.5 Тест нарастающей паузы: вторая пауза длиннее первой
- [x] 7.6 Тест имени файла: запись с именем `секретное-слово.mp3` ложится в
хранилище под именем без этого слова и с расширением `.mp3`
- [x] 7.7 Тест брошенного пути: задача, чей шаг не дошёл до объявления отказа,
после заданного числа захватов уходит в `dead`
- [x] 7.8 Тест чужого захвата: шаг, потерявший задачу за время работы, результата
не пишет и отправителю не отвечает
- [x] 7.9 Тест вида времени: время захвата, положенное **не** нашим кодом, а тем
же путём, что `created`, сравнивается со сроком верно
- [x] 7.10 Тест повтора: отказ между укладкой файла и сохранением задачи не
оставляет задачу со ссылкой на несуществующий файл
- [x] 7.11 Тест уборки: после отказа шага рабочей копии во временном каталоге
не остаётся
- [x] 7.12 Тест журнала: имени файла в хранилище в журнале нет ни на одном пути
## 8. Документы
- [x] 8.1 Переписать `docs/database.md`: коллекции вместо таблиц, новая таблица
настроек с числами (предел попыток, пауза, оба срока захвата с их потолками,
задержки опроса), уход goose и goqu
- [x] 8.2 Поправить `CLAUDE.md`: строка стека без CGO, запреты с путями под новую
раскладку, инвариант «новая колонка в четырёх местах» снять или переписать
- [x] 8.3 Поправить `docs/security.md`, раздел «Из чего строятся пути и ключи»:
раскладка хранилища, имя отправителя в путь **не** попадает, ссылка на файл и
почему она не уезжает в журнал, приглашение завести владельца
- [x] 8.4 Поправить `docs/architecture.md`: компоненты, единые точки проекта,
открытые вопросы про хранилище и очередь, преамбула про состояние спек и
маркеры долга у «Очереди таблицей»
- [x] 8.5 Поправить `docs/conventions/database.md`: миграции больше не goose,
время в сыром запросе — тем же видом, что пишет хранилище
## 9. Сборка и приёмка
- [x] 9.1 Проверить сборку образа: `task image` проходит, слой не требует CGO
- [x] 9.2 `task gate` зелёный целиком
- [x] 9.3 Прогон вживую на пустом каталоге с подставным распознавателем: запись
через `POST /api/audio` доходит до `done`, видна строкой в панели, скачивается
по `/api/files/...` той же длины
- [x] 9.4 Прогон поверхности: анонимный запрос к записям коллекций и к служебным
разделам хранилища получает отказ
+37 -16
View File
@@ -10,7 +10,6 @@
Telegram делит с ним общий шаг заведения задачи, но требований на него нет: Telegram делит с ним общий шаг заведения задачи, но требований на него нет:
требование, написанное без проверки, — предположение, а не норма. Первая задача, требование, написанное без проверки, — предположение, а не норма. Первая задача,
которая трогает поведение приёма из Telegram, дописывает его сюда. которая трогает поведение приёма из Telegram, дописывает его сюда.
## Requirements ## Requirements
### Requirement: Приём записи по HTTP ### Requirement: Приём записи по HTTP
@@ -25,13 +24,16 @@ MUST нести идентификатор задачи полем `job_id` и
Приём не судит о годности записи сам: расширение он берёт из имени файла, а Приём не судит о годности записи сам: расширение он берёт из имени файла, а
пригодность содержимого узнаёт у источника метаданных. пригодность содержимого узнаёт у источника метаданных.
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
хранилище, и нормирует её capability `storage`.
#### Scenario: Запись принята #### Scenario: Запись принята
- **GIVEN** источник метаданных читает запись и отдаёт её длительность - **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с полем `audio` - **WHEN** программа шлёт `POST /api/audio` с полем `audio`
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status` - **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
со значением `created` со значением `created`
- **AND** содержимое записи целиком лежит в каталоге хранения одним файлом - **AND** содержимое записи целиком лежит в хранилище одним файлом
#### Scenario: Поля с записью нет #### Scenario: Поля с записью нет
@@ -53,17 +55,28 @@ MUST нести идентификатор задачи полем `job_id` и
своим приёму не подконтрольно. своим приёму не подконтрольно.
Расширения в присланном имени нет — сервис MUST подставить `.audio`, чтобы у Расширения в присланном имени нет — сервис MUST подставить `.audio`, чтобы у
файла на диске расширение было всегда. файла в хранилище расширение было всегда.
Требование переживает смену раскладки. Умолчание хранилища, строящее имя из
имени отправителя, MUST не применяться: имя отправителя в журнал не пишется по
инварианту приватности, а изъятие из него кончается расширением — хвостом после
последней точки.
#### Scenario: Расширение взято из имени отправителя #### Scenario: Расширение взято из имени отправителя
- **WHEN** программа шлёт запись с именем `test.mp3` - **WHEN** программа шлёт запись с именем `test.mp3`
- **THEN** файл в каталоге хранения имеет расширение `.mp3` - **THEN** имя файла в хранилище оканчивается на `.mp3`
#### Scenario: Имени без расширения назначено своё #### Scenario: Имени без расширения назначено своё
- **WHEN** программа шлёт запись с именем `test` без расширения - **WHEN** программа шлёт запись с именем `test` без расширения
- **THEN** файл в каталоге хранения имеет расширение `.audio` - **THEN** имя файла в хранилище оканчивается на `.audio`
#### Scenario: Имя отправителя в хранилище не попало
- **WHEN** программа шлёт запись с именем `секретное-слово.mp3`
- **THEN** имя файла в хранилище не содержит `секретное-слово`
- **AND** путь к этому файлу не содержит его тоже
### Requirement: Отказ чтения метаданных ### Requirement: Отказ чтения метаданных
@@ -86,10 +99,10 @@ MUST нести идентификатор задачи полем `job_id` и
личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи, личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи,
откуда строку не убрать. откуда строку не убрать.
Расширение, взятое из этого имени, в журнале остаётся: оно стоит в собственном Расширение, взятое из этого имени, в журнале остаётся собственным полем: по нему
имени файла на диске, и по нему прослеживается путь записи. Что именно попадает в прослеживается путь записи. Что именно попадает в журнал ради прослеживаемости,
журнал ради прослеживаемости, нормирует требование ниже; наружу расширение нормирует требование ниже; наружу расширение выходит только приведённым к
выходит только приведённым к известному виду — этому отдано отдельное требование. известному виду — этому отдано отдельное требование.
Сценарии судят приём по HTTP, потому что имя, данное отправителем, доходит до Сценарии судят приём по HTTP, потому что имя, данное отправителем, доходит до
сервиса только оттуда: из Telegram приходит путь, выданный самим Telegram, а не сервиса только оттуда: из Telegram приходит путь, выданный самим Telegram, а не
@@ -119,8 +132,10 @@ MUST нести идентификатор задачи полем `job_id` и
записи и её размер в байтах. По ним путь записи собирается отбором по журналу, и записи и её размер в байтах. По ним путь записи собирается отбором по журналу, и
удаление имени отправителя прослеживаемости не отнимает. удаление имени отправителя прослеживаемости не отнимает.
Расширение засчитывается присутствием собственного имени файла в хранилище: Расширение засчитывается собственным полем журнальной строки. Имя, под которым
отдельного поля под него приём не заводит. файл лёг в хранилище, приём MUST в журнал не писать: это имя — последняя часть
ссылки на скачивание, и записанное вместе с идентификатором записи оно собирает
ссылку целиком. Норму держит capability `storage`.
#### Scenario: Идентификатор, расширение и размер на месте #### Scenario: Идентификатор, расширение и размер на месте
@@ -129,6 +144,12 @@ MUST нести идентификатор задачи полем `job_id` и
- **THEN** журнал приёма несёт идентификатор заведённого файла, расширение - **THEN** журнал приёма несёт идентификатор заведённого файла, расширение
принятой записи и её размер в байтах принятой записи и её размер в байтах
#### Scenario: Имени файла в хранилище в журнале нет
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
- **WHEN** программа шлёт `POST /api/audio` с записью
- **THEN** имени, под которым файл лёг в хранилище, в журнале приёма нет
### Requirement: Метка метрики несёт только известное расширение ### Requirement: Метка метрики несёт только известное расширение
Сервис SHALL приводить расширение принятой записи к известному виду прежде, чем Сервис SHALL приводить расширение принятой записи к известному виду прежде, чем
@@ -147,12 +168,12 @@ MUST нести идентификатор задачи полем `job_id` и
Когда конвертацию нормируют своей capability, обязанность переезжает туда вместе Когда конвертацию нормируют своей capability, обязанность переезжает туда вместе
с ней. с ней.
Имя файла на диске это требование не трогает: там расширение остаётся тем, каким Имя файла в хранилище это требование не трогает: там расширение остаётся тем,
пришло, — это уже нормировано требованием «Имя файла в хранилище». каким пришло, — это уже нормировано требованием «Имя файла в хранилище».
Настоящий формат записи, попавшей в `other`, остаётся видимым в журнале: значение Настоящий формат записи, попавшей в `other`, остаётся видимым в журнале: значение
`other` в метке означает «расширение не из перечня», а само оно стоит в поле `other` в метке означает «расширение не из перечня», а само оно стоит полем
пути журнальной строки приёма и в поле формата строки конвертации. журнальной строки приёма и полем формата строки конвертации.
#### Scenario: Незнакомое расширение наружу не выходит #### Scenario: Незнакомое расширение наружу не выходит
@@ -160,7 +181,7 @@ MUST нести идентификатор задачи полем `job_id` и
- **WHEN** программа шлёт запись с именем, чей хвост после последней точки не - **WHEN** программа шлёт запись с именем, чей хвост после последней точки не
принадлежит перечню known-форматов принадлежит перечню known-форматов
- **THEN** метка метрики принимает значение `other` - **THEN** метка метрики принимает значение `other`
- **AND** файл в каталоге хранения сохраняет пришедшее расширение - **AND** имя файла в хранилище сохраняет пришедшее расширение
#### Scenario: Известное расширение идёт как есть #### Scenario: Известное расширение идёт как есть
+185 -1
View File
@@ -13,7 +13,6 @@ transcribe → done | failed`, захват задачи и срок его пр
что такого поведения нет: оно живёт в коде, а требования на него не написаны, что такого поведения нет: оно живёт в коде, а требования на него не написаны,
потому что требование без проверки — предположение, а не норма. Первая задача, потому что требование без проверки — предположение, а не норма. Первая задача,
которая трогает любое из перечисленного, дописывает его сюда. которая трогает любое из перечисленного, дописывает его сюда.
## Requirements ## Requirements
### Requirement: Пустой прогон воркера — не отказ ### Requirement: Пустой прогон воркера — не отказ
@@ -76,3 +75,188 @@ transcribe → done | failed`, захват задачи и срок его пр
- **THEN** счётчик работы воркера растёт с пометкой успеха - **THEN** счётчик работы воркера растёт с пометкой успеха
- **AND** записи об отказе в журнале нет - **AND** записи об отказе в журнале нет
### Requirement: Захват задачи неделим
Захват задачи воркером SHALL быть одним неделимым шагом хранилища: выбор
подходящей задачи и пометка её захваченной MUST происходить вместе, и захваченная
задача MUST возвращаться тем же шагом.
Одна и та же задача MUST доставаться ровно одному захватившему. Двум вызывающим,
пришедшим за одним состоянием одновременно, запись MUST достаться одному, а
второй MUST получить признак «работы в этом состоянии нет».
Порядок выборки MUST быть определён однозначно: сравнения по неуникальному
значению для этого мало, и к нему MUST добавляться ключ записи. Иначе порядок
обработки невоспроизводим, а проверка, опирающаяся на «следующую» задачу,
зелена через раз.
Требование стоит на инварианте проекта «Принятая запись не теряется молча»:
захват, разделённый на два шага, отдаёт одну запись двум воркерам, и работа
одного из них теряется без следа.
Признак «работы нет» этим требованием не переопределяется — его нормирует
требование «Пустой прогон воркера — не отказ».
#### Scenario: За задачей пришли трое разом
- **GIVEN** в опрашиваемом состоянии лежит ровно одна задача
- **WHEN** три захвата этого состояния идут одновременно
- **THEN** запись получает ровно один из них
- **AND** двое остальных получают признак «работы в этом состоянии нет»
#### Scenario: Захваченная задача не выдаётся второй раз
- **GIVEN** задача захвачена и срок захвата не истёк
- **WHEN** за тем же состоянием приходит следующий захват
- **THEN** эта задача ему не выдаётся
### Requirement: Результат пишет только держатель захвата
Шаг конвейера SHALL записывать свой результат только тогда, когда захват задачи
всё ещё принадлежит ему. Запись MUST быть условна по признаку захвата, а шаг,
чей захват за время работы достался другому, MUST завершиться без записи
результата и без ответа отправителю.
Требование закрывает то, чего неделимость захвата не закрывает: захват протухает
не только у мёртвого воркера, но и у живого — шаг, идущий дольше своего срока,
теряет задачу, продолжая работать. Без этого условия два воркера пишут в одну
задачу по очереди, счётчик попыток сбрасывает тот, кто уже не владелец, а
отправитель получает два ответа на одну запись.
Шаг MUST записывать только те поля, которыми распоряжается сам. Задачу он держит
снимком с момента захвата и до записи — это часы, — и безусловная запись снимка
стёрла бы всё, что владелец правил в панели за это время: молча, без строки в
журнале и без отказа в панели. Владелец увидел бы успешное сохранение и был бы
уверен, что правка на месте.
#### Scenario: Правка владельца пережила сохранение шага
- **GIVEN** шаг держит захваченную задачу
- **AND** владелец за это время изменил в панели поле, которого шаг не касается
- **WHEN** шаг записывает свой результат
- **THEN** результат шага записан
- **AND** правка владельца на месте
#### Scenario: Захват ушёл под работающим шагом
- **GIVEN** шаг работает над захваченной задачей
- **AND** за это время та же задача досталась другому захвату
- **WHEN** первый шаг доходит до записи результата
- **THEN** результат не записывается
- **AND** отправителю ничего не отправляется
### Requirement: Брошенная задача возвращается в работу
Задача, захваченная и брошенная на середине, SHALL доставаться снова по
истечении срока захвата. Срок MUST считаться от времени захвата, а истёкший
захват MUST не мешать выдать задачу следующему.
Срок задаётся шагом конвейера и MUST быть не меньше того времени, которое этот
шаг может занять на самом длинном допустимом входе. Срок короче делает
протухание штатным событием живого шага, а не признаком беды.
Все значения времени, по которым идёт этот отбор, MUST записываться и сравниваться
в одном виде — том же, в каком хранилище пишет собственные времена записи.
Сравнение идёт побайтово, и вид, разошедшийся хоть разделителем, обращает
условие в постоянную истину или постоянную ложь, причём молча.
#### Scenario: Захват протух
- **GIVEN** задача захвачена, а время захвата отстоит дальше срока
- **WHEN** за её состоянием приходит захват
- **THEN** задача выдаётся ему
#### Scenario: Срок сравнивается с временем, записанным хранилищем
- **GIVEN** задача захвачена, и время захвата записано в том же виде, в каком
хранилище пишет время изменения записи
- **WHEN** за её состоянием приходит захват до истечения срока
- **THEN** задача ему не выдаётся
### Requirement: Число попыток и состояние «мертва»
У задачи 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** следующий захват выдаёт её снова
### Requirement: Пауза перед повтором нарастает
Перед повтором **отказавшей** задачи сервис SHALL выдерживать паузу, и пауза
MUST расти с числом её попыток до объявленного потолка. Задача MUST не
выдаваться захвату, пока пауза не кончилась.
Ожидание чужой операции этой паузой MUST не выражаться. Шаг, увидевший, что
внешняя операция ещё идёт, отработал без отказа: он назначает **свою** задержку
опроса, заданную числом, и попытки при этом не тратит. Пауза, выведенная из
числа попыток, на таком шаге вырождается в наименьшее своё значение и учащает
опрос внешнего сервиса во столько раз, во сколько задержка опроса длиннее секунды.
#### Scenario: Отказавшая задача ждёт
- **GIVEN** задача отказала на шаге конвейера
- **WHEN** захват приходит раньше конца её паузы
- **THEN** задача ему не выдаётся
#### Scenario: Вторая пауза длиннее первой
- **GIVEN** задача отказала дважды подряд
- **WHEN** сравнивают паузу после второго отказа с паузой после первого
- **THEN** вторая длиннее
#### Scenario: Ожидание операции не учащается и не тратит попыток
- **GIVEN** внешняя операция распознавания ещё идёт
- **WHEN** шаг проверки отрабатывает подряд несколько раз
- **THEN** задержка до следующей проверки каждый раз одна и та же
- **AND** число попыток задачи не растёт
+233
View File
@@ -0,0 +1,233 @@
# storage Specification
## Purpose
TBD - created by archiving change pocketbase-storage. Update Purpose after archive.
## Requirements
### Requirement: Сервис поднимается на чистом каталоге данных
Сервис SHALL приводить хранилище в рабочий вид сам: на пустом каталоге данных он
MUST завести свою схему и принимать записи обоими входами без единого ручного
шага до первого запуска.
Прежние данные не переносятся. Каталог, оставшийся от прежней раскладки, MUST не
читаться и не считаться источником: сервис начинает с чистого листа, и это
решение задачи, а не следствие отказа.
Схема MUST заводиться версионированными шагами, а применённый шаг MUST не
переписываться — только новым шагом. Иначе повторный запуск на уже заведённом
каталоге разошёлся бы с первым молча.
Каталог данных у сервиса MUST быть один: база и файлы записей лежат под ним
вместе, и второго пути к ним не заводится.
#### Scenario: Первый запуск на пустом каталоге
- **GIVEN** каталог данных пуст
- **WHEN** сервис запускается
- **THEN** он заводит своё хранилище и продолжает работу
- **AND** принятая следом запись доходит до состояния `done`
#### Scenario: Повторный запуск на заведённом каталоге
- **GIVEN** сервис уже запускался на этом каталоге и завёл хранилище
- **WHEN** он запускается снова
- **THEN** он не заводит схему второй раз и не теряет прежние записи
### Requirement: Файл записи живёт в хранилище
Сервис SHALL держать файл записи в хранилище, а не отдельным каталогом рядом с
ним. Файл MUST попадать туда вместе с записью, которой принадлежит, и MUST
адресоваться этой записью, а не путём на диске.
Раскладку файлов на диске выбирает хранилище. Собственного плоского каталога
записей у сервиса MUST не оставаться: файл, лежащий мимо хранилища, не попадёт
ни в панель владельца, ни в резервную копию, а ради этих двух вещей перевод и
делается.
Содержимое записи MUST не читаться в память целиком ни при укладке в хранилище,
ни при чтении из него: расчётный потолок записи — шесть часов, и такая запись в
память не помещается.
**Потолок размера записи MUST быть задан числом, выведенным из этого расчётного
потолка**, и задан он MUST быть везде, где иначе действует чужое умолчание: и у
поля файла в хранилище, и у тела запроса приёма. Умолчания здесь не «без
предела», а величины на два-три порядка меньше нужного, и оставленные как есть
они отвергают штатную запись сервиса — приём отказывает, а уже принятая запись
исчерпывает попытки на шаге конвертации.
Отказ по этому потолку MUST быть виден отправителю ответом, а не молчанием.
Шаги, которым нужен файл именем на диске — конвертация и чтение метаданных
отдают его внешней программе, — MUST получать рабочую копию **одним общим
способом**, и у этого способа MUST быть единственный способ её убрать. Уборку
зовёт шаг, и звать её он MUST на любом исходе, включая отказ. Заводить копию по
месту шагам MUST не приходиться: иначе обязанность прибрать переписывается
столько раз, сколько шагов, а забытая копия — это шестичасовая запись,
оставшаяся во временном каталоге, и узнать о ней неоткуда.
#### Scenario: Принятая запись легла в хранилище
- **WHEN** запись принята любым входом
- **THEN** её файл лежит в хранилище и связан со своей записью
- **AND** отдельного каталога записей рядом с хранилищем не появляется
#### Scenario: Запись длиннее чужого умолчания принимается
- **WHEN** в хранилище кладут запись длиннее умолчания, действующего у поля файла
- **THEN** она ложится в хранилище, а не отвергается
#### Scenario: Шаг конвейера берёт файл по записи
- **GIVEN** запись принята и её файл лежит в хранилище
- **WHEN** шаг конвейера берётся за эту запись
- **THEN** он получает файл по самой записи, а не по пути на диске
#### Scenario: Рабочая копия убрана после отказа шага
- **GIVEN** шагу выдана рабочая копия файла
- **WHEN** шаг завершается отказом
- **THEN** рабочей копии во временном каталоге не остаётся
### Requirement: Файл отдаётся ссылкой
Сервис SHALL отдавать файл записи ссылкой, которую строит хранилище по самой
записи. Отданный файл MUST совпадать с принятым по длине.
Ссылка на несуществующую запись MUST отвечать отказом, а не пустым файлом.
**Ссылка сама по себе и есть право пройти по ней**, и потому она MUST не попадать
ни в журнал, ни в метку метрики, ни в ответ отправителю. Имя, под которым файл
лёг в хранилище, из журнала выводимо быть не должно: журнал уезжает в собранные
логи, откуда строку не убрать, и оттуда ссылка на чужую запись работала бы
бессрочно.
Отсюда требование к отказам: сообщение об отказе хранилища MUST не выходить за
пределы хранилища дословно. Отказ чтения и отказ укладки называют ключ файла
целиком, а отказ выгрузки во внешнее хранилище — полный адрес объекта; и то и
другое кончается в журнале и собирает ссылку не хуже успешного пути.
Что именно журнал приёма пишет ради прослеживаемости, нормирует capability
`intake`.
#### Scenario: Файл забирают по ссылке
- **GIVEN** запись принята и её файл лежит в хранилище
- **WHEN** ссылку на файл запрашивают
- **THEN** приходит тот же файл, и его длина совпадает с длиной принятого
#### Scenario: Ссылка ведёт в никуда
- **WHEN** запрашивают ссылку на запись, которой нет
- **THEN** приходит отказ, а не пустой ответ
#### Scenario: По журналу ссылку не собрать
- **GIVEN** запись принята и прошла конвейер
- **WHEN** читают журнал сервиса целиком
- **THEN** имени, под которым файл лёг в хранилище, в нём нет
#### Scenario: Отказ чтения файла не называет его ключ
- **GIVEN** файл записи не читается из хранилища
- **WHEN** шаг конвейера берётся за эту запись и отказывает
- **THEN** отказ называет запись её идентификатором и не несёт имени файла
### Requirement: Наружу хранилище отдаёт только то, что заказано
Сервис SHALL держать закрытыми собственные разделы хранилища, которые тот
публикует тем же портом. Запрос без прав владельца MUST получать отказ на
перечисление и чтение записей коллекций, на служебные разделы хранилища —
журналы запросов, резервные копии, настройки, расписание — и на правку чего бы
то ни было.
Требование заводится потому, что порт опубликован в интернет, а вместе с
переводом наружу выходит поверхность, которой у сервиса не было. Что API сервиса
сегодня открыт всякому — известно и записано моделью угроз; новая поверхность под
это знание не подпадает и закрывается здесь.
Правило доступа, оставленное пустым, значит «только владелец панели». Именно
пустым оно MUST и оставаться: непустое правило, поставленное будущей правкой
схемы, открыло бы перечисление всех записей анонимному запросу и не нарушило бы
при этом ни одного другого требования.
#### Scenario: Аноним перечисляет записи
- **WHEN** запрос без прав владельца просит список записей коллекции задач
- **THEN** приходит отказ
#### Scenario: Аноним читает служебный раздел
- **WHEN** запрос без прав владельца просит журнал запросов или список резервных
копий хранилища
- **THEN** приходит отказ
### Requirement: Владелец видит записи в панели
Сервис SHALL давать владельцу панель, где задача видна строкой, отбирается по
своему идентификатору и правится, а её файл слушается и скачивается.
Панель MUST отдаваться тем же сервисом по своему адресу и MUST не требовать
второго процесса.
Панель — вход в задачу наравне с конвейером, а не окно просмотра, и правка
состояния задачи в ней MUST подчиняться тем же правилам перехода, что и правка
из кода: служебные поля прошлого состояния — признак захвата, время захвата,
пауза, число попыток — MUST очищаться. Иначе владелец, вернувший мёртвую задачу в
работу, получит задачу, которая не выдаётся захвату до конца прежнего срока и
умирает от первого же отказа, — и не узнает об этом.
Задача, заведённая в панели руками, MUST не уносить сервис: поля, без которых
шаг конвейера не может работать, MUST быть обязательными в самой схеме, а
перечень состояний — закрытым.
Панель разграничению доступа сервиса не подчиняется: вошедший в неё видит все
записи, все файлы и всех пользователей разом. Закрывает её контур выкладки, а не
сервис — это записано моделью угроз проекта.
#### Scenario: Принятая запись видна владельцу
- **GIVEN** запись принята и её задача заведена
- **WHEN** владелец отбирает задачи по идентификатору принятой
- **THEN** он видит её строкой со своим состоянием
- **AND** файл этой записи скачивается из той же строки
#### Scenario: Мёртвую задачу вернули в работу правкой в панели
- **GIVEN** задача в состоянии «мертва» с исчерпанными попытками и признаком
прежнего захвата
- **WHEN** владелец меняет её состояние на рабочее
- **THEN** признак захвата, время захвата, пауза и число попыток очищены
- **AND** ближайший захват выдаёт задачу
### Requirement: Пароль владельца от панели не лежит в конфигурации
Сервис SHALL не заводить в конфигурации ключа под пароль владельца от панели.
Пароль MUST задаваться самим владельцем, а хранилище MUST держать только его
отпечаток.
Требование стоит на инварианте проекта «Секрет не покидает конфиг» с другой
стороны: секрет, которого в конфигурации нет, не утекает вместе с ней и не
уезжает в выкладку третьим путём. Пароль от панели открывает все записи и все
файлы разом — это самое чувствительное, что есть у сервиса.
Приглашение завести владельца сервис MUST печатать только пока владельца нет, и
оно MUST истекать по времени. Приглашение равносильно паролю от панели, а
печатается оно в журнал контейнера, откуда строку не убрать: бессрочное отдало бы
панель всякому читателю логов навсегда.
Пока владелец пароля не задал, сервис MUST работать обоими входами: панель без
владельца не мешает принимать записи.
#### Scenario: Владелец пароля ещё не задал
- **GIVEN** каталог данных пуст и владелец панели не заведён
- **WHEN** сервис запускается
- **THEN** он принимает записи обоими входами
- **AND** ни один ключ конфигурации не несёт пароля от панели
#### Scenario: Владелец заведён, приглашение больше не печатается
- **GIVEN** владелец панели заведён
- **WHEN** сервис запускается снова
- **THEN** приглашения завести владельца в журнале нет