хранилище, файлы записей и очередь переведены на встроенную PocketBase
- записи, метаданные и файлы съехались под один каталог данных; появилась панель владельца, а gin, goqu, goose и требование CGO ушли - захват задачи стал одним запросом с RETURNING; заведены число попыток, состояние dead и нарастающая пауза вместо признака is_error - имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с идентификатором записи оно собирало бы ссылку на скачивание
This commit is contained in:
@@ -11,8 +11,9 @@
|
||||
|
||||
Сервис расшифровки аудио в текст. Принимает запись двумя входами — Telegram-бот и
|
||||
HTTP API, — конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание
|
||||
Yandex SpeechKit и возвращает текст туда, откуда пришла запись. Состояние задач
|
||||
и метаданные файлов лежат в SQLite, файлы — на диске.
|
||||
Yandex SpeechKit и возвращает текст туда, откуда пришла запись. Состояние задач,
|
||||
метаданные и сами файлы лежат во встроенной PocketBase, и она же даёт владельцу
|
||||
панель администратора.
|
||||
|
||||
Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст
|
||||
руками не правит и в форматы документов не экспортирует, учётных записей не
|
||||
@@ -23,10 +24,10 @@ Yandex SpeechKit и возвращает текст туда, откуда пр
|
||||
|
||||
## Стек
|
||||
|
||||
Go 1.24 (нужен CGO из-за `mattn/go-sqlite3`), gin, goqu, goose, SQLite,
|
||||
`go-telegram-bot-api`, `aws-sdk-go-v2` для Object Storage, gRPC-клиент Yandex
|
||||
SpeechKit v3, Prometheus, `slog`. Сборка — Taskfile, образ — Docker,
|
||||
выкладка — Ansible из `pet-project-server`.
|
||||
Go 1.25 (CGO не нужен), встроенная PocketBase — хранилище, файлы записей и
|
||||
панель администратора, — `go-telegram-bot-api`, `aws-sdk-go-v2` для Object
|
||||
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка —
|
||||
Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`.
|
||||
|
||||
## Инварианты
|
||||
|
||||
@@ -40,8 +41,9 @@ SpeechKit v3, Prometheus, `slog`. Сборка — Taskfile, образ — Dock
|
||||
пользователя и его сообщение в лог не пишутся — только длина и
|
||||
идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера.
|
||||
**critical**
|
||||
*Изъятие:* расширение — хвост после последней точки — в журнал попадает, потому
|
||||
что стоит в собственном имени файла на диске, а путь логируется. Изъятие узкое
|
||||
*Изъятие:* расширение — хвост после последней точки — в журнал попадает
|
||||
собственным полем: по нему прослеживается путь записи. Имени файла в журнале
|
||||
нет вовсе (инвариант ниже). Изъятие узкое
|
||||
и кончается журналом: наружу, меткой метрики, расширение выходит только
|
||||
приведённым к перечню известных форматов. Границу держит спека `intake`,
|
||||
цена — [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**
|
||||
- **Миграция, уехавшая на сервер, не переписывается.** Изменение — только новым
|
||||
файлом. Необратимо: goose считает применённую версию по номеру. **critical**
|
||||
- **Новая колонка правится во всех четырёх местах** репозитория SQLite —
|
||||
`Create`, `Save`, `GetByID`, `FindAndAcquire`. Компилятор расхождение не
|
||||
поймает, а проявится оно как потерянное при сохранении поле. **major**
|
||||
файлом шага. Необратимо: хранилище считает применённое по имени файла.
|
||||
**critical**
|
||||
- **Имя файла в хранилище задаёт сервис, а в журнал не идёт.** Умолчание
|
||||
PocketBase строит имя из имени, данного отправителем, — оно не применяется.
|
||||
Само имя — последняя часть ссылки `/api/files/...`, поэтому в журнал пишется
|
||||
расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой
|
||||
записи. **critical**
|
||||
- **Колонки очереди правятся в четырёх местах** пакета хранилища —
|
||||
`applyToRecord`, `recordToJob`, константа `acquireColumns` и структура
|
||||
`acquiredRow` с её `toJob`, — плюс шаг схемы. Компилятор видит два из них.
|
||||
Колонка, забытая в паре `acquireColumns`/`acquiredRow`, приезжает из захвата
|
||||
нулевой, и первый же `Save` пишет этот ноль поверх сохранённого значения:
|
||||
поле теряется **только у задачи, попавшей к воркеру**. **major**
|
||||
- **Результат пишет только держатель захвата.** Шаг, чей захват за время работы
|
||||
достался другому, завершается без записи и без ответа отправителю. Иначе два
|
||||
воркера пишут в одну задачу по очереди, а отправитель получает два ответа.
|
||||
**major**
|
||||
|
||||
## Команды
|
||||
|
||||
```bash
|
||||
go build ./... # нужен CGO
|
||||
go build ./... # CGO не нужен
|
||||
go test ./...
|
||||
go vet ./...
|
||||
gofmt -l .
|
||||
@@ -118,10 +133,10 @@ task gate # весь набор проверок разом
|
||||
|
||||
## Запреты
|
||||
|
||||
- **Рабочую БД не трогать.** `data/transcriber.db` на сервере и его копии.
|
||||
Локальная база в `./data/` — своя, её ронять и пересоздавать можно свободно.
|
||||
- **Боевой каталог записей не трогать.** `data/files` на сервере: там лежат
|
||||
голосовые сообщения живых людей.
|
||||
- **Боевой каталог данных не трогать.** `data/` на сервере целиком: под ним и
|
||||
база (`data/data.db`), и записи живых людей
|
||||
(`data/storage/<коллекция>/<запись>/`). Локальный каталог данных — свой, его
|
||||
ронять и пересоздавать можно свободно.
|
||||
- **Боевым токеном бота не запускаться.** Второй процесс с тем же токеном
|
||||
перехватывает обновления у работающего, и пользователь теряет ответы.
|
||||
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
|
||||
@@ -139,7 +154,7 @@ task gate # весь набор проверок разом
|
||||
- **Основная ветка:** `master`. Коммиты идут в неё напрямую, веток и PR нет.
|
||||
- **Сообщение коммита** без трейлера `Co-Authored-By`.
|
||||
- **Необратимое** (спрашивается у человека всегда): применённая миграция, формат
|
||||
файла на диске и раскладка `data/files`, публичный контракт HTTP API, имя
|
||||
файла на диске и раскладка каталога данных, публичный контракт HTTP API, имя
|
||||
ключа конфига, любое действие с боевыми данными и с Yandex Cloud, ротация
|
||||
секрета.
|
||||
- **Что считается сломанным** — новый красный шаг гейта, которого не было до
|
||||
|
||||
+4
-7
@@ -1,11 +1,8 @@
|
||||
# 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
|
||||
RUN apk --no-cache add \
|
||||
build-base \
|
||||
sqlite-dev \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
# Сборочных зависимостей нет: хранилище ходит в SQLite через modernc.org/sqlite,
|
||||
# и CGO больше не требуется.
|
||||
|
||||
# Set up the working directory
|
||||
WORKDIR /app
|
||||
@@ -20,7 +17,7 @@ RUN go mod download
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN go build -o transcriber .
|
||||
RUN CGO_ENABLED=0 go build -o transcriber .
|
||||
|
||||
# ----------------
|
||||
# Production stage
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- Конвертация в ogg через ffmpeg
|
||||
- Распознавание речи через Yandex SpeechKit
|
||||
- Отслеживание статуса задач расшифровки
|
||||
- SQLite для хранения метаданных, метрики Prometheus
|
||||
- Встроенная PocketBase для метаданных, файлов и панели владельца; метрики Prometheus
|
||||
|
||||
## Технологии
|
||||
|
||||
@@ -17,9 +17,8 @@
|
||||
- **Telegram**: go-telegram-bot-api
|
||||
- **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3)
|
||||
- **Конвертация**: ffmpeg
|
||||
- **SQL Builder**: doug-martin/goqu
|
||||
- **Миграции БД**: pressly/goose
|
||||
- **База данных**: SQLite
|
||||
- **Хранилище, файлы и панель**: встроенная PocketBase
|
||||
- **База данных**: SQLite внутри PocketBase (через modernc.org/sqlite, CGO не нужен)
|
||||
- **Метрики**: prometheus/client_golang
|
||||
|
||||
## Установка и запуск
|
||||
@@ -97,36 +96,26 @@ transcriber/
|
||||
│ ├── metaviewer/ffmpeg/ # Длительность аудио
|
||||
│ ├── recognizer/yandex/ # SpeechKit + Object Storage
|
||||
│ ├── telegram/ # Отправка сообщений
|
||||
│ └── repo/sqlite/ # Репозитории
|
||||
├── migrations/ # Миграции goose, вшиты в бинарник через go:embed
|
||||
└── data/
|
||||
├── files/ # Директория для сохранения файлов
|
||||
└── transcriber.db # SQLite база данных (создается автоматически)
|
||||
│ └── repo/pocketbase/ # Репозитории, схема коллекций, правила панели
|
||||
└── data/ # Каталог данных: база и файлы записей вместе
|
||||
├── data.db # База хранилища (создаётся автоматически)
|
||||
└── storage/ # Файлы записей в раскладке хранилища
|
||||
```
|
||||
|
||||
## База данных
|
||||
## Хранилище
|
||||
|
||||
Две таблицы, `files` и `transcribe_jobs`. Колонки, ключи, правило времени и
|
||||
Две коллекции, `files` и `transcribe_jobs`. Поля, ключи, правило времени и
|
||||
идентификаторов, а также механика захвата задачи воркером —
|
||||
[docs/database.md](docs/database.md).
|
||||
[docs/database.md](docs/database.md). Панель владельца — по адресу `/_/` того же
|
||||
порта; пароль от неё задаёт сам владелец по приглашению, которое сервис печатает
|
||||
в журнал при первом запуске.
|
||||
|
||||
## Разработка
|
||||
|
||||
Миграции накатываются автоматически при старте сервиса — они вшиты в бинарник
|
||||
через `//go:embed`. Достаточно положить новый файл в `migrations/`.
|
||||
|
||||
Создать файл миграции и накатить или откатить её вручную:
|
||||
|
||||
```bash
|
||||
# Создание новой миграции
|
||||
goose -dir migrations create migration_name sql
|
||||
|
||||
# Применение миграций
|
||||
goose -dir migrations sqlite3 data/transcriber.db up
|
||||
|
||||
# Откат миграций
|
||||
goose -dir migrations sqlite3 data/transcriber.db down
|
||||
```
|
||||
Схему двигают шаги миграций PocketBase на Go —
|
||||
`internal/adapter/repo/pocketbase`. Непринятые шаги накатываются при подъёме
|
||||
хранилища, прежде чем стартуют воркеры и сервер. Применённый шаг не
|
||||
переписывается: изменение — только новым файлом шага.
|
||||
|
||||
Проверки перед коммитом — одной командой:
|
||||
|
||||
|
||||
+3
-6
@@ -4,13 +4,10 @@ port = 8080
|
||||
shutdown_timeout = 5
|
||||
force_shutdown_timeout = 20
|
||||
|
||||
# Database configuration
|
||||
[database]
|
||||
path = "data/transcriber.db"
|
||||
|
||||
# File storage configuration
|
||||
# Storage configuration
|
||||
# Единственный каталог данных: под ним лежат и база, и файлы записей.
|
||||
[storage]
|
||||
path = "data/files"
|
||||
data_dir = "data"
|
||||
|
||||
# Yandex Cloud Configuration
|
||||
[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`, а не на прежних путях. Данные при этом не
|
||||
переносятся по решению задачи, так что цена нулевая ровно сейчас и была бы не
|
||||
нулевой при переносе.
|
||||
@@ -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-errcheck-check-blank.md) | |
|
||||
| 2026-08-11 | [Наружу расширение выходит только приведённым к перечню](ADR-2026-08-11-known-format-label.md) | |
|
||||
|
||||
+35
-36
@@ -8,16 +8,19 @@
|
||||
[passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из
|
||||
этого ещё не решено — в разделе «Открытые вопросы».
|
||||
|
||||
Заведены две capability, и каждая описана частично:
|
||||
Заведены три capability:
|
||||
|
||||
- [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: его
|
||||
нормируют проверки, написанные задачей `http-handler-tests-never-green`
|
||||
2026-08-11;
|
||||
- [pipeline](../openspec/specs/pipeline/spec.md) — **только пустой прогон
|
||||
воркера**: задача `errors-as-instead-of-typecast` 2026-08-11. Переходы
|
||||
состояний, захват и срок его протухания, отмена контекста посреди шага в неё
|
||||
**не** переехали и остаются долгом; что именно не описано, перечисляет раздел
|
||||
`Purpose` самой спеки.
|
||||
- [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
|
||||
задачи и срок его протухания, число попыток, состояние «мертва» и пауза перед
|
||||
повтором: задачи `errors-as-instead-of-typecast` 2026-08-11 и
|
||||
`pocketbase-storage` 2026-08-12. Переходы состояний и отмена контекста посреди шага остаются
|
||||
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
|
||||
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
|
||||
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
|
||||
2026-08-12.
|
||||
|
||||
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в
|
||||
коде. Задача, которая его трогает, дописывает спеку своей capability.
|
||||
@@ -26,17 +29,16 @@
|
||||
|
||||
- **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и
|
||||
делят одну базу. Отдельного воркер-процесса нет намеренно.
|
||||
- **Очередь таблицей.** Состояние задачи лежит в SQLite, воркер забирает работу
|
||||
запросом с захватом. Внешний брокер не заводим: нагрузка — единицы записей в
|
||||
день (оценка владельца, не замер). Готовую библиотеку очереди тоже не заводим —
|
||||
решено 2026-08-11,
|
||||
- **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища, воркер
|
||||
забирает работу одним запросом с захватом. Внешний брокер не заводим: нагрузка
|
||||
— единицы записей в день (оценка владельца, не замер). Готовую библиотеку
|
||||
очереди тоже не заводим — решено 2026-08-11,
|
||||
[ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение
|
||||
кандидатов в [research/job-queue.md](research/job-queue.md).
|
||||
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: спека заведена, но это в ней не описано -->
|
||||
- **Шаг конвейера идемпотентен по повтору.** Задача, брошенная на середине,
|
||||
достаётся снова по истечении срока захвата и проходит шаг заново.
|
||||
- **Ядро зависит от интерфейсов.** `internal/service` знает только
|
||||
`internal/contract`; ffmpeg, Yandex, Telegram и SQLite подставляются в
|
||||
`internal/contract`; ffmpeg, Yandex, Telegram и хранилище подставляются в
|
||||
`main.go`.
|
||||
|
||||
## Компоненты
|
||||
@@ -54,12 +56,15 @@
|
||||
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
|
||||
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit |
|
||||
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
|
||||
| Репозитории | `internal/adapter/repo/sqlite` | Задачи и файлы, запросы через goqu |
|
||||
| Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом |
|
||||
| Панель владельца | там же, `panel.go` | Правка задачи в панели проходит те же правила перехода, что и правка из кода |
|
||||
|
||||
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: спека заведена, но это в ней не описано -->
|
||||
|
||||
Конвейер: `created` → `converted` → `transcribe` → `done` либо `failed`. Три
|
||||
воркера двигают по одному переходу, каждый опрашивает базу раз в секунду.
|
||||
воркера двигают по одному переходу, каждый опрашивает базу раз в секунду. Задача,
|
||||
исчерпавшая попытки, уходит в `dead` мимо этой цепочки: её переводит туда не шаг,
|
||||
а тот, кто её захватил.
|
||||
|
||||
## Внешние границы и форматы
|
||||
|
||||
@@ -91,7 +96,7 @@
|
||||
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
|
||||
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
|
||||
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла» | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
|
||||
| SQLite (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
|
||||
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
|
||||
| Диск | Запись файла падает, задача не заводится | — | — | — |
|
||||
|
||||
- **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота
|
||||
@@ -107,7 +112,9 @@
|
||||
| Что | Где |
|
||||
| --- | --- |
|
||||
| Приём аудио и заведение задачи | `TranscribeService.createTranscribeJob` — через него идут оба входа |
|
||||
| Захват задачи воркером | `TranscriptJobRepository.FindAndAcquire` |
|
||||
| Правка задачи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает |
|
||||
| Захват задачи воркером | `TranscriptJobRepository.FindAndAcquire` — один запрос с `RETURNING` |
|
||||
| Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг |
|
||||
| Переход задачи в состояние | `entity.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния |
|
||||
| Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю |
|
||||
| Разбор конфигурации | `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, а ответ провайдера
|
||||
обрабатывает PocketBase, а не наш код (тот же ADR). Не решено, где живёт сессия
|
||||
обрабатывает PocketBase, а не наш код
|
||||
([ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)). Не решено, где живёт сессия
|
||||
и как связываются пользователь Telegram и пользователь веба. Панель
|
||||
администратора при этом Authelia не закрывает: у неё свой пароль
|
||||
суперпользователя.
|
||||
@@ -168,25 +170,22 @@
|
||||
копится — записи о потреблении или счётчики — решает задача
|
||||
`usage-accounting`.
|
||||
- **Срок хранения.** Записи и тексты решено хранить бессрочно (паспорт,
|
||||
2026-08-11), а рост каталога `data/files` ничем не ограничен и не наблюдается.
|
||||
2026-08-11), а рост каталога данных ничем не ограничен и не наблюдается.
|
||||
- **Резервные копии.** Копии делает сервер своими средствами, и приложение о них
|
||||
ничего не знает. После переезда на PocketBase не решено, хватит ли копировать
|
||||
её каталог файлами, или приложению нужна команда выгрузки: база под нагрузкой
|
||||
копируется файлом не всегда целой. Своё копирование по расписанию у PocketBase
|
||||
есть — берём мы его или нет, тоже не решено.
|
||||
ничего не знает. Не решено, хватит ли копировать каталог данных файлами, или
|
||||
приложению нужна команда выгрузки: база под нагрузкой копируется файлом не
|
||||
всегда целой. Своё копирование по расписанию у PocketBase есть — берём мы его
|
||||
или нет, тоже не решено.
|
||||
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
|
||||
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
|
||||
сервис определяет содержимое сам, то ли часть записей теряется на этом.
|
||||
- **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но
|
||||
конвертер этот случай не проверялся.
|
||||
- **Очередь.** Модель очереди решена 2026-08-11: остаётся своей таблицей и
|
||||
становится коллекцией PocketBase, захват сворачивается в один запрос с
|
||||
`RETURNING`, число попыток ложится колонкой, а исчерпавшая их задача переходит
|
||||
в состояние «мертва» вместо `is_error = 1`
|
||||
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)). Пишет это
|
||||
`pocketbase-storage` тем же заходом, что и хранилище. Не решено, отказываться
|
||||
ли от холостого опроса: три воркера дают 259 200 запросов в сутки при нагрузке
|
||||
в единицы записей в день, и во что это обходится, никто не мерил.
|
||||
- **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12
|
||||
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)) и нормирована
|
||||
спекой `pipeline`. Не решено, отказываться ли от холостого опроса: три воркера
|
||||
дают 259 200 запросов в сутки при нагрузке в единицы записей в день, и во что
|
||||
это обходится, никто не мерил.
|
||||
- **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать
|
||||
счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой —
|
||||
решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
|
||||
- **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется
|
||||
**приложением** в момент создания записи.
|
||||
*Расхождение:* идентификаторы записей выдаёт хранилище — 15 знаков
|
||||
собственного алфавита. Своей точки генерации у приложения нет, и `ORDER BY id`
|
||||
хронологией не является: порядок берут по колонке времени с ключом.
|
||||
- Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология),
|
||||
компактен и удобен в URL и логах (без дефисов — grep и двойной клик берут id
|
||||
целиком), глобально уникален между таблицами — поиск по голому id находит все
|
||||
@@ -43,14 +46,21 @@
|
||||
|
||||
- Enum-поля (`state`, `source`, …) — обычный `TEXT` без `CHECK`; допустимые
|
||||
значения держит код.
|
||||
*Расхождение:* перечень состояний задачи закрыт схемой (`SelectField`), а не
|
||||
кодом — ради панели владельца: правка руками не должна заводить состояние,
|
||||
которого конвейер не знает. Цена названа: шестое состояние потребует нового
|
||||
шага схемы.
|
||||
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например
|
||||
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
|
||||
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
|
||||
Единая точка генерации — приложение, а не умолчание в схеме: так забытая
|
||||
вставка падает громко. Измерение длительности — не метка времени.
|
||||
- Миграции — goose (`migrations/`): SQL-файлы для DDL; Go-миграции
|
||||
(`goose.AddMigrationContext`) — когда нужен код (генерация id, заполнение
|
||||
задним числом). При изменении структуры обновляем схему
|
||||
- Миграции — шаги PocketBase на Go (`internal/adapter/repo/pocketbase`):
|
||||
коллекции и их поля заводятся кодом. При изменении структуры обновляем схему
|
||||
[../database.md](../database.md) тем же изменением.
|
||||
- Добавляя колонку, соблюдай инвариант «Новая колонка правится во всех четырёх
|
||||
местах» — [CLAUDE.md](../../CLAUDE.md), «Инварианты».
|
||||
- Время в **сыром запросе** кладётся и сравнивается тем же видом, каким
|
||||
хранилище пишет свои `created`/`updated`. Сравнение строк побайтово, и
|
||||
разошедшийся вид обращает условие в постоянную истину или ложь — молча.
|
||||
- Выборка «следующей» записи с `LIMIT 1` дополняется ключом в `ORDER BY`:
|
||||
сравнение по неуникальному значению делает порядок обработки
|
||||
невоспроизводимым.
|
||||
|
||||
@@ -202,8 +202,13 @@ Object Storage, скачивание файла из Telegram и опрос оп
|
||||
периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом
|
||||
`INFO` они не пишутся.
|
||||
|
||||
*Расхождение:* `sloggin` пишет все запросы одинаково, `/health` и `/metrics`
|
||||
попадают в лог наравне с остальными.
|
||||
Расхождения здесь больше нет: слой журналирования запросов свой,
|
||||
`main.go`, хук `OnServe` — вместе с gin ушёл и `sloggin`. `/health` и `/metrics`
|
||||
идут на `DEBUG`, то есть при боевом `INFO` не пишутся вовсе.
|
||||
|
||||
Хранилище ведёт **свой** журнал запросов в собственной таблице, и он виден
|
||||
владельцу в панели. Заменой потоку процесса он не служит: в журнал контейнера,
|
||||
по которому разбирают отказы, эта таблица не попадает.
|
||||
|
||||
## Безопасность: что не логируем
|
||||
|
||||
|
||||
+107
-59
@@ -1,101 +1,149 @@
|
||||
# Схема хранилища
|
||||
|
||||
СУБД, миграции, правило времени и идентификаторов.
|
||||
Хранилище, коллекции, правило времени и идентификаторов.
|
||||
|
||||
СУБД — SQLite, драйвер `mattn/go-sqlite3` (нужен CGO). Запросы строит
|
||||
`doug-martin/goqu` с диалектом `sqlite3`. Миграции — `pressly/goose`, каталог
|
||||
`migrations/`, вшит в бинарник через `//go:embed migrations/*.sql` в `main.go` и
|
||||
накатывается при старте. Новый файл достаточно положить в каталог.
|
||||
Хранилище — **встроенная PocketBase 0.39.10**: она держит и базу, и файлы
|
||||
записей под одним каталогом данных. Ключ конфигурации — `[storage] data_dir`,
|
||||
умолчание `data`. В SQLite библиотека ходит через `modernc.org/sqlite`, поэтому
|
||||
CGO сборке не нужен.
|
||||
|
||||
**Идентификаторы** — UUID v4 строкой.
|
||||
Схему двигают **шаги миграций PocketBase** на Go, каталог
|
||||
`internal/adapter/repo/pocketbase`, файл шага — `migrations.go`. Шаг
|
||||
регистрируется при загрузке пакета, а накатывается при подъёме хранилища
|
||||
(`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый шаг не
|
||||
переписывается — изменение только новым шагом: применённое хранилище считает по
|
||||
имени файла.
|
||||
|
||||
**Время** — локальная зона процесса, UTC не навязан. Колонки `created_at` и
|
||||
`updated_at` проставляет приложение, а не СУБД; умолчание `CURRENT_TIMESTAMP`
|
||||
стоит только у `files.created_at`.
|
||||
**Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита.
|
||||
Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в
|
||||
хранилище, задаёт сервис, и это `<uuid><расширение>`.
|
||||
|
||||
**Время** — вид хранилища: строка `2006-01-02 15:04:05.000Z` в UTC. Колонки
|
||||
`created` и `updated` проставляет само хранилище; те же поля в сыром запросе
|
||||
захвата кладёт наш код — **тем же видом**, потому что сравнение строк в SQLite
|
||||
побайтово, и разошедшийся вид обратил бы условие срока в постоянную истину или
|
||||
постоянную ложь молча.
|
||||
|
||||
Того, что единой точки генерации идентификатора и времени нет, здесь не
|
||||
повторяем: перечень единых точек и их отсутствий держит
|
||||
[architecture.md](architecture.md), «Единые точки проекта».
|
||||
|
||||
Переход на PocketBase запланирован, но не начат — здесь описано сегодняшнее
|
||||
состояние. Открытые вопросы перехода — в
|
||||
[architecture.md](architecture.md), раздел «Открытые вопросы».
|
||||
|
||||
## Таблицы
|
||||
## Коллекции
|
||||
|
||||
### `files`
|
||||
|
||||
Один файл на одну физическую копию: исходник, результат конвертации и копия в
|
||||
Object Storage — три разные записи.
|
||||
|
||||
| Колонка | Тип | Что |
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `id` | TEXT PK | UUID файла |
|
||||
| `storage` | TEXT | `local` или `s3` |
|
||||
| `file_name` | TEXT | Имя в хранилище: UUID с расширением |
|
||||
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
||||
| `file` | file | Сам файл; пусто у копии в Object Storage |
|
||||
| `location` | select | `local` или `s3` |
|
||||
| `object_key` | TEXT | Ключ объекта; пусто у местной копии |
|
||||
| `size` | INTEGER | Размер в байтах |
|
||||
| `created_at` | DATETIME | Умолчание `CURRENT_TIMESTAMP` |
|
||||
| `created`, `updated` | DATETIME | Проставляет хранилище |
|
||||
|
||||
Поле названо `location`, а не `storage`: последним словом зовут само хранилище и
|
||||
capability, и третий смысл развёл бы одно слово по разным вещам.
|
||||
|
||||
### `transcribe_jobs`
|
||||
|
||||
Задача расшифровки и она же очередь.
|
||||
|
||||
| Колонка | Тип | Что |
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `id` | TEXT PK | UUID задачи |
|
||||
| `state` | TEXT | `created`, `converted`, `transcribe`, `done`, `failed` |
|
||||
| `source` | TEXT | `api`, `telegram`, `unknown`; умолчание `unknown` |
|
||||
| `file_id` | TEXT FK → `files.id` | **Текущий** файл задачи: шаг конвейера переставляет ссылку на свой результат |
|
||||
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
||||
| `state` | select | `created`, `converted`, `transcribe`, `done`, `failed`, `dead`; перечень закрыт схемой |
|
||||
| `source` | select | `api`, `telegram`, `unknown` |
|
||||
| `file` | relation → `files` | **Текущий** файл задачи: шаг конвейера переставляет ссылку на свой результат |
|
||||
| `delay_time` | DATETIME | Не брать задачу раньше этого времени |
|
||||
| `acquisition_id` | TEXT | Кто захватил задачу |
|
||||
| `acquire_time` | DATETIME | Когда захватил; по нему считается протухание |
|
||||
| `attempts` | INTEGER ≥ 0 | Число попыток: растёт при захвате, обнуляется на шаге без отказа |
|
||||
| `recognition_op_id` | TEXT | Идентификатор операции в Yandex Cloud |
|
||||
| `transcription_text` | TEXT | Результат распознавания |
|
||||
| `is_error` | BOOLEAN | Задача с `1` из выборки исключена навсегда |
|
||||
| `transcription_text` | editor | Результат распознавания |
|
||||
| `error_text` | TEXT | Текст ошибки, машинный |
|
||||
| `tg_chat_id` | INTEGER | Куда отправить результат |
|
||||
| `tg_reply_message_id` | INTEGER | С каким сообщением связать |
|
||||
| `created_at`, `updated_at` | DATETIME | Проставляет приложение |
|
||||
| `created`, `updated` | DATETIME | Проставляет хранилище |
|
||||
|
||||
Индексов, кроме первичных ключей, нет. Выборка воркера идёт полным перебором по
|
||||
`state`, `is_error`, `delay_time` и `acquire_time`.
|
||||
Индекс один — по `state`: выборка воркера идёт по нему, паузе и сроку захвата.
|
||||
Прежней колонки `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 не удаляются
|
||||
после завершения задачи: каталог и бакет растут неограниченно.
|
||||
- **Захват задачи — два запроса подряд, не транзакция.** Сперва `UPDATE …
|
||||
WHERE id = (SELECT … LIMIT 1)` проставляет `acquisition_id`, затем отдельный
|
||||
`SELECT … WHERE acquisition_id = ?` читает строку. Репозиторий сверяет число
|
||||
затронутых строк с ожидаемым, но между запросами задачу может перехватить
|
||||
другой воркер с тем же значением — на одном процессе это не наблюдалось.
|
||||
- **Список колонок задан не одним местом** — четырьмя запросами файла
|
||||
`internal/adapter/repo/sqlite/transcript_job_repo.go`. Правило правки всех
|
||||
четырёх и его severity — инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты».
|
||||
целиком при каждом чтении задачи и при каждом захвате.
|
||||
- **Аудио лежит в раскладке хранилища:**
|
||||
`data/storage/<коллекция>/<запись>/<имя>` рядом с файлом атрибутов. Имя задаёт
|
||||
сервис — `<uuid><расширение>`; собственного суффикса хранилище не дописывает,
|
||||
потому что умолчание, строящее имя из имени отправителя, не применяется. Ни
|
||||
файлы, ни объекты в Object Storage не удаляются после завершения задачи:
|
||||
каталог и бакет растут неограниченно.
|
||||
- **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
|
||||
не помечено защищённым: право прочитать запись даёт знание её идентификатора,
|
||||
и файл встаёт вровень с опросом готовности задачи. Поэтому имя файла в
|
||||
хранилище **в журнал не пишется** — оно последняя часть ссылки.
|
||||
- **Захват задачи — один запрос с `RETURNING`**, мимо записей коллекции.
|
||||
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
|
||||
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
|
||||
заведения **и по ключу**: время неуникально, и без ключа порядок обработки
|
||||
невоспроизводим.
|
||||
- **Запись результата условна по признаку захвата.** Шаг, чей захват за время
|
||||
работы достался другому, завершается без записи и без ответа отправителю.
|
||||
- **Список колонок задан четырьмя местами** — `applyToRecord`, `recordToJob`,
|
||||
константой `acquireColumns` и структурой `acquiredRow`, — плюс шагом схемы.
|
||||
Все четыре лежат в одном пакете, но компилятор видит два: правило правки и его
|
||||
серьёзность (critical/major) — инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты».
|
||||
- **Отказ хранилища наружу не выходит дословно.** Он несёт ключ файла целиком, а
|
||||
ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают
|
||||
свой текст с идентификатором записи, а цепочку `%w` обрывают. То же у выгрузки
|
||||
в Object Storage: отказ SDK несёт полный URL объекта.
|
||||
|
||||
## Настройки с числовым значением
|
||||
|
||||
| Настройка | Значение | Где |
|
||||
| --- | --- | --- |
|
||||
| Срок захвата, конвертация и распознавание | 1 час | `service/transcribe.go`, вызовы `findJob` |
|
||||
| Срок захвата, проверка операции | 24 часа | там же |
|
||||
| Пауза воркера между попытками | 1 секунда | `controller/worker/worker.go` |
|
||||
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` |
|
||||
| Задержка между проверками операции | 5 секунд | там же |
|
||||
| Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` |
|
||||
| Память под multipart-загрузку | 32 МиБ | `main.go`, `router.MaxMultipartMemory` |
|
||||
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` |
|
||||
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` |
|
||||
| Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` |
|
||||
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` |
|
||||
| Настройка | Значение | Где | Откуда число |
|
||||
| --- | --- | --- | --- |
|
||||
| Предел попыток | 5 | `service/transcribe.go` | обычное умолчание, не замер |
|
||||
| Пауза перед повтором | `2^(попытка−1)` с, потолок 5 минут | там же | то же |
|
||||
| Срок захвата, конвертация | 8 часов | там же | потолок записи 6 часов плюс запас |
|
||||
| Срок захвата, распознавание | 8 часов | там же | то же |
|
||||
| Срок захвата, проверка операции | 1 час | там же | опрос идёт секунды |
|
||||
| Задержка перед первой проверкой операции | 10 секунд | там же | как было |
|
||||
| Задержка между проверками операции | 5 секунд | там же | как было |
|
||||
| Пауза воркера между попытками | 1 секунда | `controller/worker/worker.go` | как было |
|
||||
| Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` | предел Telegram |
|
||||
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
|
||||
| Таймаут жёсткой остановки | 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 и
|
||||
SpeechKit тоже нет — ни одного.
|
||||
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
|
||||
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
|
||||
библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело
|
||||
на 32 МиБ раньше обработчика. Оставленные умолчания отвергали бы всё длиннее
|
||||
примерно пяти минут. Таймаут чтения запроса снят: шесть часов записи по
|
||||
медленному каналу переживают любой фиксированный, а стойкость к целенаправленной
|
||||
нагрузке объявлена вне модели угроз.
|
||||
|
||||
Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер
|
||||
пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения
|
||||
файлов и объектов нет вовсе. Таймаутов у
|
||||
обращений к Telegram, S3 и SpeechKit тоже нет — ни одного.
|
||||
|
||||
@@ -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 | [Фреймворк приложения: Svelte, Vue и React на одном экране](spa-framework.md) | Размер собранной статики, цена шага сборки, что у трёх кандидатов одинаково |
|
||||
| 2026-08-11 | [Очередь задач: своя таблица против готовой библиотеки](job-queue.md) | Цена River и goqite в пакетах, захват одним запросом, чего нет для PocketBase |
|
||||
|
||||
@@ -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). Потоковую укладку это подтверждает, предел
|
||||
— нет.
|
||||
- **Поведение под одновременной правкой панели и конвейера в бою.** Проверено
|
||||
тестом на одной машине, не живой нагрузкой.
|
||||
@@ -69,6 +69,10 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
|
||||
Проверено загрузкой файла в 200 КБ: имя `sample.ogg` превратилось в
|
||||
`sample_uztrv6wvz3.ogg`, рядом лёг файл атрибутов.
|
||||
|
||||
*Уточнено 2026-08-12:* суффикс дописывает конструктор имени, а не укладка. Имя,
|
||||
заданное после конструктора, ложится на диск дословно — см.
|
||||
[pocketbase-defaults.md](pocketbase-defaults.md).
|
||||
|
||||
Сегодняшняя раскладка `data/files` с именами-UUID панели не видна. Путь она
|
||||
покажет строкой — прослушать и скачать запись по ней нельзя. Способа сослаться
|
||||
на файл, уже лежащий на диске мимо её каталога, нет.
|
||||
|
||||
@@ -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 — норма требовала от сервиса недостижимого [пойман ревью]
|
||||
|
||||
- **Где:** дельта-спека `pipeline` задачи `errors-as-instead-of-typecast`, абзац
|
||||
|
||||
+33
-26
@@ -85,16 +85,27 @@ Telegram отправителю.
|
||||
Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда
|
||||
путь не предполагался.
|
||||
|
||||
- **Путь на диске** — `filepath.Join(cfg.Storage.Path, fileId + ext)`, где
|
||||
`fileId` наш UUID, а **`ext` берётся из имени файла отправителя** через
|
||||
`filepath.Ext`. Расширение в путь попадает без проверки списком; `filepath.Ext`
|
||||
режет по последней точке и не пропускает разделитель каталогов, но это
|
||||
единственное, что стоит между входом и именем файла.
|
||||
- **Путь на диске** выбирает хранилище:
|
||||
`data/storage/<коллекция>/<запись>/<имя>`. **Имя задаёт сервис** —
|
||||
`<uuid><расширение>`, — а умолчание PocketBase, строящее имя из имени
|
||||
отправителя, не применяется: имя отправителя в хранилище не попадает.
|
||||
Расширение берётся из имени отправителя через `filepath.Ext` без проверки
|
||||
списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
|
||||
каталогов, но это единственное, что стоит между входом и именем файла.
|
||||
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
|
||||
расширением. Бакет один на все записи, префикса по пользователю нет.
|
||||
- **Каталог** один и плоский: `data/files` целиком, вложенности нет.
|
||||
- **Идентификатор задачи** — UUID v4. Он же единственное, что защищает
|
||||
`GET /api/status/:id`.
|
||||
- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла не
|
||||
помечено защищённым, поэтому ссылка сама по себе и есть право пройти по ней, а
|
||||
отзыва у неё нет. Отсюда запрет: **имя файла в хранилище в журнал не пишется**
|
||||
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку
|
||||
целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
|
||||
- **Идентификатор задачи** — 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`) ложатся рядом с исходным в тот же
|
||||
плоский каталог — раскладка `data/files` меняется, и это необратимо.
|
||||
- **Раскладку выбирает PocketBase** (`pocketbase-storage`), и плоского каталога
|
||||
не остаётся вовсе: файл ложится в
|
||||
`pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>` рядом с
|
||||
файлом атрибутов. Имя, данное отправителем, в путь при этом попадает — сегодня
|
||||
от него берётся только расширение. Файл уходит не с диска напрямую, а по ссылке
|
||||
вида `/api/files/<коллекция>/<запись>/<имя>`; закрытым он становится, только если
|
||||
поле помечено защищённым, и тогда нужен отдельный файловый токен. Замер —
|
||||
[research/pocketbase.md](research/pocketbase.md).
|
||||
плоский каталог — раскладка каталога данных меняется, и это необратимо.
|
||||
- **Имя отправляемого документа** (`long-text-delivery`) собирается из
|
||||
идентификатора задачи: имя, данное пользователем, в него не попадает.
|
||||
|
||||
@@ -180,10 +183,14 @@ Telegram отправителю.
|
||||
5. **Статистика потребления** (`usage-accounting`). Текста записей не содержит,
|
||||
но говорит, кто и когда пользовался сервисом и сколько; страница расхода
|
||||
открыта только владельцу.
|
||||
6. **Пароль суперпользователя панели** (`pocketbase-storage`). Открывает все
|
||||
записи, все файлы и всех пользователей разом, то есть стоит вровень с самым
|
||||
чувствительным из списка выше. Второй секрет после токенов пользователей,
|
||||
который лежит **не в конфигурации**: его отпечаток хранит сама база.
|
||||
6. **Пароль владельца от панели.** Открывает все записи, все файлы и всех
|
||||
пользователей разом, то есть стоит вровень с самым чувствительным из списка
|
||||
выше. Второй секрет после токенов пользователей, который лежит **не в
|
||||
конфигурации**: его отпечаток хранит сама база, а задаёт пароль сам владелец
|
||||
по приглашению, которое сервис печатает в журнал при первом запуске. У
|
||||
приглашения тридцать минут жизни, и после того как владелец заведён, оно не
|
||||
печатается вовсе — иначе строка журнала отдавала бы панель всякому его
|
||||
читателю навсегда.
|
||||
|
||||
Тексты расшифровок в логи не пишутся — логируется длина текста и
|
||||
идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано
|
||||
@@ -194,10 +201,10 @@ Telegram отправителю.
|
||||
инварианта приватности из [../CLAUDE.md](../CLAUDE.md), а не незакрытый остаток.
|
||||
Расширение берётся из имени отправителя дословно (`filepath.Ext`), поэтому имя
|
||||
`запись.тайное-слово` отдаёт `тайное-слово`, а `Разговор с Петровым 11.08` —
|
||||
`08`. Оно стоит в собственном имени файла на диске, а путь к файлу логируется.
|
||||
Читает этот журнал владелец сервиса. Нормализация расширения на диске — отдельная
|
||||
работа, задачи на неё пока нет: формат имени файла объявлен необратимым и меняется
|
||||
решением человека.
|
||||
`08`. В журнал оно идёт собственным полем, а не в составе имени файла: по нему
|
||||
прослеживается путь записи. Читает этот журнал владелец сервиса. Нормализация
|
||||
расширения в хранилище — отдельная работа, задачи на неё пока нет: формат имени
|
||||
файла объявлен необратимым и меняется решением человека.
|
||||
|
||||
**Наружу хвост не выходит.** Метки метрик (`file_extension` у
|
||||
`transcriber_input_file_size_bytes`, `source_format` у
|
||||
@@ -236,7 +243,7 @@ Telegram отправителю.
|
||||
замер: `research/` пуст, потолок длины стоит открытым вопросом
|
||||
`architecture.md`, «Долгие записи», — а квот нет и не будет: решено считать расход и показывать его владельцу, а не отказывать
|
||||
(цель `usage-stats`). Перебравшего останавливает разговор или отзыв доступа в
|
||||
Authelia. Рост каталога `data/files` при этом ничем не наблюдается —
|
||||
Authelia. Рост каталога данных при этом ничем не наблюдается —
|
||||
открытый вопрос `architecture.md`.
|
||||
- **Перерасход денег на внешних сервисах.** Распознавание и языковая модель
|
||||
оплачиваются по факту; потолка на пользователя нет по тому же решению.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module git.vakhrushev.me/av/transcriber
|
||||
|
||||
go 1.24.5
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
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/feature/s3/manager v1.18.3
|
||||
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/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mattn/go-sqlite3 v1.14.31
|
||||
github.com/pressly/goose/v3 v3.24.3
|
||||
github.com/pocketbase/dbx v1.12.0
|
||||
github.com/pocketbase/pocketbase v0.39.10
|
||||
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/yandex-cloud/go-genproto v0.17.0
|
||||
google.golang.org/grpc v1.74.2
|
||||
)
|
||||
|
||||
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/feature/ec2/imds v1.18.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/ssooidc v1.32.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/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/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/gabriel-vasile/mimetype v1.4.4 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.22.0 // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/ganigeorgiev/fexpr v0.6.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.23 // 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/pocketbase/ozzo-validation/v4 v4.3.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.65.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.opentelemetry.io/otel v1.36.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.36.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.38.0 // indirect
|
||||
golang.org/x/net v0.40.0 // indirect
|
||||
golang.org/x/sync v0.14.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/image v0.44.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.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/rpc v0.0.0-20250528174236-200df99c418a // indirect
|
||||
google.golang.org/protobuf v1.36.7 // 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
|
||||
)
|
||||
|
||||
@@ -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/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
|
||||
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
||||
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/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg=
|
||||
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/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.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/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/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
|
||||
github.com/doug-martin/goqu/v9 v9.19.0 h1:PD7t1X3tRcUiSdc5TEyOFKujZA5gs3VSA7wxSvBx7qo=
|
||||
github.com/doug-martin/goqu/v9 v9.19.0/go.mod h1:nf0Wc2/hV3gYK9LiyqIrzBEVGlI8qW3GuDCEobC4wBQ=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
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/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
|
||||
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
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/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
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-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
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-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
||||
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/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/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
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/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/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/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/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/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
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/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.1 h1:6VXZrLU0jHBYyAqrSPa+MgPfnSvTPuMgK+k0o5kVFWo=
|
||||
github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
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/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
|
||||
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
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/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM=
|
||||
github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E=
|
||||
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
|
||||
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/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
|
||||
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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
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/samber/slog-gin v1.15.1/go.mod h1:mPAEinK/g2jPLauuWO11m3Q0Ca7aG4k9XjXjXY8IhMQ=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
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/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/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo=
|
||||
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.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
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=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
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.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.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/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw=
|
||||
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 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y=
|
||||
modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
|
||||
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
||||
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/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
|
||||
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
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=
|
||||
|
||||
@@ -2,6 +2,7 @@ package yandex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/smithy-go"
|
||||
)
|
||||
|
||||
type s3Config struct {
|
||||
@@ -72,7 +74,16 @@ func (s *yandexS3Service) uploadFile(file io.Reader, fileName string) error {
|
||||
Body: file,
|
||||
})
|
||||
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
|
||||
|
||||
@@ -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 }
|
||||
@@ -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, "правка владельца пережила сохранение шага")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `toml:"server"`
|
||||
Database DatabaseConfig `toml:"database"`
|
||||
Storage StorageConfig `toml:"storage"`
|
||||
Yandex YandexConfig `toml:"yandex"`
|
||||
Telegram TelegramConfig `toml:"telegram"`
|
||||
@@ -22,12 +21,10 @@ type ServerConfig struct {
|
||||
UsersWhiteList []string `toml:"users_while_list"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Path string `toml:"path"`
|
||||
}
|
||||
|
||||
// StorageConfig — единственный каталог данных: под ним лежат и база, и файлы
|
||||
// записей. Двух путей, как было раньше, у хранилища не бывает.
|
||||
type StorageConfig struct {
|
||||
Path string `toml:"path"`
|
||||
DataDir string `toml:"data_dir"`
|
||||
}
|
||||
|
||||
type YandexConfig struct {
|
||||
@@ -53,11 +50,8 @@ func defaultConfig() *Config {
|
||||
ShutdownTimeout: 5,
|
||||
ForceShutdownTimeout: 20,
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
Path: "data/transcriber.db",
|
||||
},
|
||||
Storage: StorageConfig{
|
||||
Path: "data/files",
|
||||
DataDir: "data",
|
||||
},
|
||||
Yandex: YandexConfig{
|
||||
FolderID: "",
|
||||
|
||||
@@ -11,6 +11,18 @@ func (e *JobNotFoundError) Error() string {
|
||||
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 {
|
||||
State string
|
||||
}
|
||||
|
||||
@@ -1,19 +1,58 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"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 {
|
||||
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)
|
||||
// Open отдаёт содержимое хранимого файла потоком.
|
||||
Open(fileID string) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
type TranscriptJobRepository interface {
|
||||
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)
|
||||
// FindAndAcquire забирает задачу одним неделимым шагом и увеличивает число
|
||||
// её попыток. Работы в состоянии нет — JobNotFoundError.
|
||||
FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error)
|
||||
}
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"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/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type TranscribeHandler struct {
|
||||
jobRepo contract.TranscriptJobRepository
|
||||
trsService *service.TranscribeService
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewTranscribeHandler(jobRepo contract.TranscriptJobRepository, trsService *service.TranscribeService) *TranscribeHandler {
|
||||
return &TranscribeHandler{jobRepo: jobRepo, trsService: trsService}
|
||||
func NewTranscribeHandler(jobRepo contract.TranscriptJobRepository, trsService *service.TranscribeService, logger *slog.Logger) *TranscribeHandler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &TranscribeHandler{jobRepo: jobRepo, trsService: trsService, logger: logger}
|
||||
}
|
||||
|
||||
type CreateTranscribeJobResponse struct {
|
||||
@@ -31,74 +39,56 @@ type GetTranscribeJobResponse struct {
|
||||
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 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No audio file provided"})
|
||||
return
|
||||
return e.JSON(http.StatusBadRequest, map[string]string{"error": "No audio file provided"})
|
||||
}
|
||||
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)
|
||||
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,
|
||||
State: job.State,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, response)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TranscribeHandler) GetTranscribeJobStatus(c *gin.Context) {
|
||||
jobID := c.Param("id")
|
||||
func (h *TranscribeHandler) GetTranscribeJobStatus(e *core.RequestEvent) error {
|
||||
jobID := e.Request.PathValue("id")
|
||||
|
||||
job, err := h.jobRepo.GetByID(jobID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
||||
return
|
||||
return e.JSON(http.StatusNotFound, map[string]string{"error": "Job not found"})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, GetTranscribeJobResponse{
|
||||
return e.JSON(http.StatusOK, GetTranscribeJobResponse{
|
||||
JobID: job.Id,
|
||||
State: job.State,
|
||||
CreatedAt: job.CreatedAt,
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -10,30 +9,22 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"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/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/entity"
|
||||
"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. Проверки судят приём — что
|
||||
@@ -72,21 +63,19 @@ func readableMetaViewer() *stubMetaViewer {
|
||||
return &stubMetaViewer{seconds: 42}
|
||||
}
|
||||
|
||||
// testEnv — собранное окружение одной проверки. Каталог хранения свой у
|
||||
// каждой: рабочий каталог процесса проверки не трогают.
|
||||
// testEnv — собранное окружение одной проверки. Каталог данных свой у каждой:
|
||||
// рабочий каталог процесса проверки не трогают.
|
||||
type testEnv struct {
|
||||
router *gin.Engine
|
||||
mux http.Handler
|
||||
handler *TranscribeHandler
|
||||
db *sql.DB
|
||||
storageDir string
|
||||
app core.App
|
||||
journal *journalBuffer
|
||||
}
|
||||
|
||||
// journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на
|
||||
// пакет сделал бы исход функцией от соседних случаев — «поля на месте» прошло бы
|
||||
// на чужой строке, а «маркера нет» покраснело бы от чужой. Замок нужен потому,
|
||||
// что пишущих в него потоков три: логгер сервиса, стандартный `log` транспорта
|
||||
// и middleware запроса.
|
||||
// что пишущих в него потоков два: логгер сервиса и логгер обработчика.
|
||||
type journalBuffer struct {
|
||||
mu sync.Mutex
|
||||
text strings.Builder
|
||||
@@ -106,44 +95,29 @@ func (b *journalBuffer) String() string {
|
||||
return b.text.String()
|
||||
}
|
||||
|
||||
func setupTestDB(t *testing.T) (*sql.DB, *goqu.Database) {
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
// newTestStorage поднимает хранилище на пустом каталоге и накатывает схему —
|
||||
// ровно тем же путём, каким это делает сервис при старте.
|
||||
func newTestStorage(t *testing.T) core.App {
|
||||
t.Helper()
|
||||
|
||||
app, err := pbrepo.New(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { db.Close() })
|
||||
t.Cleanup(func() {
|
||||
if err := app.ResetBootstrapState(); err != nil {
|
||||
t.Logf("не удалось закрыть хранилище: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Каждому новому соединению с `:memory:` драйвер выдаёт свою базу, и
|
||||
// второй потребитель пула не увидел бы накатанных миграций. Одно
|
||||
// соединение снимает класс целиком.
|
||||
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
|
||||
return app
|
||||
}
|
||||
|
||||
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")
|
||||
require.NoError(t, os.MkdirAll(storageDir, 0o755))
|
||||
|
||||
fileRepo := sqlite.NewFileRepository(db, gq)
|
||||
jobRepo := sqlite.NewTranscriptJobRepository(db, gq)
|
||||
fileRepo := pbrepo.NewFileRepository(app)
|
||||
jobRepo := pbrepo.NewTranscriptJobRepository(app)
|
||||
|
||||
// Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на
|
||||
// имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки
|
||||
@@ -152,20 +126,6 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
||||
journal := &journalBuffer{}
|
||||
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(
|
||||
jobRepo,
|
||||
fileRepo,
|
||||
@@ -173,27 +133,21 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
||||
&stubConverter{},
|
||||
&recognizer.MemoryAudioRecognizer{},
|
||||
&TestTgSender{},
|
||||
storageDir,
|
||||
logger,
|
||||
)
|
||||
|
||||
handler := NewTranscribeHandler(jobRepo, trsService)
|
||||
handler := NewTranscribeHandler(jobRepo, trsService, logger)
|
||||
|
||||
// Роутер собирается той же цепочкой, что и боевой (main.go): у приёма три
|
||||
// пишущих в журнал потока, и middleware — третий. Без него требование «ни
|
||||
// одна журнальная запись приёма» проверялось бы шире, чем оракул смотрит.
|
||||
router := gin.New()
|
||||
router.Use(sloggin.New(logger))
|
||||
router.Use(gin.Recovery())
|
||||
router.MaxMultipartMemory = 32 << 20 // 32 MiB
|
||||
// Роутер собирается тем же способом, что и боевой: маршруты вешает сам
|
||||
// обработчик, и проверка судит ту же цепочку, что и прод.
|
||||
r, err := apis.NewRouter(app)
|
||||
require.NoError(t, err)
|
||||
handler.Register(r)
|
||||
|
||||
api := router.Group("/api")
|
||||
{
|
||||
api.POST("/audio", handler.CreateTranscribeJob)
|
||||
api.GET("/status/:id", handler.GetTranscribeJobStatus)
|
||||
}
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
return &testEnv{router: router, handler: handler, db: db, storageDir: storageDir, journal: journal}
|
||||
return &testEnv{mux: mux, handler: handler, app: app, journal: journal}
|
||||
}
|
||||
|
||||
// createMultipartRequest собирает запрос из имени и содержимого. Файла на диске
|
||||
@@ -217,35 +171,67 @@ func createMultipartRequestWithField(t *testing.T, field, fileName string, conte
|
||||
err = writer.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
req, err := http.NewRequest("POST", "/api/audio", &buf)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/audio", &buf)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
// storedFiles отдаёт содержимое каталога хранения.
|
||||
func storedFiles(t *testing.T, env *testEnv) []string {
|
||||
files, err := filepath.Glob(filepath.Join(env.storageDir, "*"))
|
||||
// storedFileNames отдаёт имена, под которыми файлы легли в хранилище.
|
||||
func storedFileNames(t *testing.T, env *testEnv) []string {
|
||||
records, err := env.app.FindAllRecords(pbrepo.FilesCollection)
|
||||
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 считает заведённые задачи расшифровки.
|
||||
func countJobs(t *testing.T, env *testEnv) int {
|
||||
var count int
|
||||
err := env.db.QueryRow("SELECT COUNT(*) FROM transcribe_jobs").Scan(&count)
|
||||
records, err := env.app.FindAllRecords(pbrepo.JobsCollection)
|
||||
require.NoError(t, err)
|
||||
return count
|
||||
return len(records)
|
||||
}
|
||||
|
||||
// storedFileName отдаёт имя файла, записанное в учёте под данным идентификатором.
|
||||
func storedFileName(t *testing.T, env *testEnv, fileID string) string {
|
||||
var name string
|
||||
err := env.db.QueryRow("SELECT file_name FROM files WHERE id = ?", fileID).Scan(&name)
|
||||
// jobWithFile заводит задачу вместе с её записью: ссылка на файл обязательна
|
||||
// схемой, потому что без неё задача не пройдёт ни одного шага.
|
||||
func jobWithFile(t *testing.T, env *testEnv) *entity.TranscribeJob {
|
||||
t.Helper()
|
||||
|
||||
repo := pbrepo.NewFileRepository(env.app)
|
||||
work, err := repo.Stage(".mp3", strings.NewReader("запись"))
|
||||
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) {
|
||||
@@ -255,7 +241,7 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
|
||||
req := createMultipartRequest(t, "sample.m4a", content)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
@@ -285,17 +271,9 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
|
||||
require.NotNil(t, job.FileID)
|
||||
assert.NotEmpty(t, *job.FileID)
|
||||
|
||||
// Содержимое лежит в каталоге хранения одним файлом и целиком.
|
||||
files := storedFiles(t, env)
|
||||
require.Len(t, files, 1)
|
||||
|
||||
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))
|
||||
// Содержимое лежит в хранилище одним файлом и целиком.
|
||||
require.Equal(t, 1, countFiles(t, env))
|
||||
assert.Equal(t, content, storedContent(t, env, *job.FileID))
|
||||
}
|
||||
|
||||
func TestCreateTranscribeJob_NoFile(t *testing.T) {
|
||||
@@ -308,9 +286,9 @@ func TestCreateTranscribeJob_NoFile(t *testing.T) {
|
||||
{
|
||||
name: "no body at all",
|
||||
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())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, tc.req(t))
|
||||
env.mux.ServeHTTP(w, tc.req(t))
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
@@ -335,7 +313,7 @@ func TestCreateTranscribeJob_NoFile(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
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))
|
||||
})
|
||||
}
|
||||
@@ -349,7 +327,7 @@ func TestCreateTranscribeJob_EmptyFile(t *testing.T) {
|
||||
req := createMultipartRequest(t, "empty.m4a", nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
@@ -396,28 +374,52 @@ func TestCreateTranscribeJob_DifferentFileExtensions(t *testing.T) {
|
||||
req := createMultipartRequest(t, tc.fileName, []byte("запись"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
files := storedFiles(t, env)
|
||||
require.Len(t, files, 1)
|
||||
names := storedFileNames(t, env)
|
||||
require.Len(t, names, 1)
|
||||
|
||||
// Имя отправителя в хранилище не попадает: имя файла — свой
|
||||
// идентификатор, от отправителя взято только расширение.
|
||||
assert.Equal(t, tc.expectExt, filepath.Ext(files[0]))
|
||||
assert.NotContains(t, filepath.Base(files[0]), tc.fileName)
|
||||
// идентификатор, от отправителя взято только расширение. Суффикс
|
||||
// дописывает само хранилище, поэтому сверяем хвост, а не Ext.
|
||||
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) {
|
||||
env := setupTestEnv(t, &stubMetaViewer{err: errors.New("не удалось прочитать запись")})
|
||||
|
||||
req := createMultipartRequest(t, "broken.m4a", []byte("не запись вовсе"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
|
||||
@@ -439,14 +441,14 @@ func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) {
|
||||
// пропустил бы.
|
||||
const senderNameMarker = "SENDERNAMELEAKMARKER7Q2"
|
||||
|
||||
// Тексты, по которым проверки находят журнальные строки. Оба — записанный долг
|
||||
// `docs/conventions/logging.md`: `msg` обязан стать короткой категорией, а
|
||||
// транспорту не положено логировать вовсе. Когда долг закроют, правка будет
|
||||
// здесь и одна, а смысл утверждений менять не придётся.
|
||||
// Тексты, по которым проверки находят журнальные строки. Первый — записанный
|
||||
// долг `docs/conventions/logging.md`: `msg` обязан стать короткой категорией.
|
||||
// Когда долг закроют, правка будет здесь и одна.
|
||||
const (
|
||||
msgIntake = "Creating transcribe job"
|
||||
msgTransportErr = "Err:"
|
||||
msgMiddleware = "Incoming request"
|
||||
// Отказ пишет доменная граница — приём, — а не транспорт: конвенция просит
|
||||
// логировать ошибку один раз, и повторная запись транспорта снята.
|
||||
msgIntakeErr = "Failed to get file info"
|
||||
)
|
||||
|
||||
func TestCreateTranscribeJob_SenderFileNameNotLogged(t *testing.T) {
|
||||
@@ -457,17 +459,16 @@ func TestCreateTranscribeJob_SenderFileNameNotLogged(t *testing.T) {
|
||||
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("запись"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
journal := env.journal.String()
|
||||
|
||||
// Сперва — что поток middleware вообще перехвачен. Он третий писатель
|
||||
// журнала приёма, и без этого утверждения снятие его из тестового роутера
|
||||
// сузило бы оракул молча.
|
||||
require.Contains(t, journal, msgMiddleware,
|
||||
"строка middleware о запросе попадает в перехваченный журнал")
|
||||
// Сперва — что журнал приёма вообще перехвачен. Без этого утверждения
|
||||
// пустой буфер сделал бы проверку запрета зелёной, ничего не прочитав.
|
||||
require.Contains(t, journal, msgIntake,
|
||||
"строка приёма попадает в перехваченный журнал")
|
||||
|
||||
assert.NotContains(t, journal, senderNameMarker,
|
||||
"имя, данное отправителем, не пишется в журнал: инвариант приватности")
|
||||
@@ -481,22 +482,45 @@ func TestCreateTranscribeJob_SenderFileNameNotLoggedOnFailure(t *testing.T) {
|
||||
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("не запись вовсе"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
|
||||
journal := env.journal.String()
|
||||
|
||||
// Сперва — что второй поток журнала вообще перехвачен. Без этого
|
||||
// утверждения снятие `slog.SetDefault` из окружения оставило бы проверку
|
||||
// зелёной, а оракул критического инварианта молча сузился бы вдвое.
|
||||
require.Contains(t, journal, msgTransportErr,
|
||||
"строка транспорта, идущая мимо slog, попадает в перехваченный журнал")
|
||||
// Сперва — что журнал ветки отказа вообще перехвачен: обработчик пишет свою
|
||||
// строку, и без неё оракул молча сузился бы вдвое.
|
||||
require.Contains(t, journal, msgIntakeErr,
|
||||
"строка приёма об отказе попадает в перехваченный журнал")
|
||||
|
||||
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) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
@@ -504,7 +528,7 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
|
||||
req := createMultipartRequest(t, "sample.mp3", content)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
@@ -569,7 +593,7 @@ func TestCreateTranscribeJob_MetricLabelCarriesNoSenderName(t *testing.T) {
|
||||
req := createMultipartRequest(t, "sample."+senderNameMarker, []byte("запись"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
@@ -582,41 +606,30 @@ func TestCreateTranscribeJob_MetricLabelCarriesNoSenderName(t *testing.T) {
|
||||
assert.Contains(t, values, "other",
|
||||
"незнакомое расширение приведено к общему значению")
|
||||
|
||||
// А на диске расширение остаётся пришедшим: раскладка каталога записей
|
||||
// объявлена необратимой, и приведение сюда не распространяется.
|
||||
files := storedFiles(t, env)
|
||||
require.Len(t, files, 1)
|
||||
assert.Equal(t, "."+senderNameMarker, filepath.Ext(files[0]))
|
||||
// А в хранилище расширение остаётся пришедшим: приведение сюда не
|
||||
// распространяется.
|
||||
names := storedFileNames(t, env)
|
||||
require.Len(t, names, 1)
|
||||
assert.True(t, strings.HasSuffix(names[0], "."+senderNameMarker),
|
||||
"имя в хранилище сохраняет пришедшее расширение: %q", names[0])
|
||||
}
|
||||
|
||||
func TestGetTranscribeJobStatus_Success(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
job := &entity.TranscribeJob{
|
||||
Id: "test-job-id",
|
||||
State: entity.StateCreated,
|
||||
Source: entity.SourceApi,
|
||||
FileID: nil,
|
||||
IsError: false,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
job := jobWithFile(t, env)
|
||||
|
||||
err := env.handler.jobRepo.Create(job)
|
||||
require.NoError(t, err)
|
||||
|
||||
req, err := http.NewRequest("GET", "/api/status/test-job-id", nil)
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response GetTranscribeJobResponse
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
||||
|
||||
assert.Equal(t, "test-job-id", response.JobID)
|
||||
assert.Equal(t, job.Id, response.JobID)
|
||||
assert.Equal(t, entity.StateCreated, response.State)
|
||||
assert.NotZero(t, response.CreatedAt)
|
||||
}
|
||||
@@ -624,21 +637,12 @@ func TestGetTranscribeJobStatus_Success(t *testing.T) {
|
||||
func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
job := &entity.TranscribeJob{
|
||||
Id: "job-without-text",
|
||||
State: entity.StateCreated,
|
||||
Source: entity.SourceApi,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
job := jobWithFile(t, env)
|
||||
|
||||
err := env.handler.jobRepo.Create(job)
|
||||
require.NoError(t, err)
|
||||
|
||||
req, err := http.NewRequest("GET", "/api/status/job-without-text", nil)
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
@@ -646,8 +650,7 @@ func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) {
|
||||
// читается клиентом как «расшифровка пуста», и разобранная структура
|
||||
// эти два случая не различает.
|
||||
var raw map[string]json.RawMessage
|
||||
err = json.Unmarshal(w.Body.Bytes(), &raw)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
|
||||
|
||||
assert.Contains(t, raw, "job_id")
|
||||
assert.Contains(t, raw, "status")
|
||||
@@ -658,17 +661,15 @@ func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) {
|
||||
func TestGetTranscribeJobStatus_NotFound(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
req, err := http.NewRequest("GET", "/api/status/non-existent-id", nil)
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest("GET", "/api/status/non-existent-id", http.NoBody)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, w.Code)
|
||||
|
||||
var response map[string]string
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
||||
|
||||
assert.Equal(t, "Job not found", response["error"])
|
||||
}
|
||||
|
||||
+21
-13
@@ -4,25 +4,33 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Где лежит копия файла. Поле названо `location`, а не `storage`: последним
|
||||
// словом зовут само хранилище, и третий смысл у одного слова развёл бы по
|
||||
// разным вещам запись о файле и хранилище, в котором она лежит.
|
||||
const (
|
||||
StorageLocal = "local"
|
||||
StorageS3 = "s3"
|
||||
LocationLocal = "local"
|
||||
LocationS3 = "s3"
|
||||
)
|
||||
|
||||
// MaxRecordSize — потолок размера одного файла записи. Выведен из расчётного
|
||||
// потолка записи в шесть часов с запасом на видео, а не из замера.
|
||||
//
|
||||
// Число нужно назвать **явно** в двух местах сразу: у поля файла в хранилище
|
||||
// нулевой потолок значит не «без предела», а умолчание библиотеки в 5 МиБ, а у
|
||||
// тела запроса приёма умолчание роутера отсекало бы запись раньше, чем она
|
||||
// дойдёт до обработчика — без строки в журнале приёма.
|
||||
const MaxRecordSize int64 = 8 << 30 // 8 ГиБ
|
||||
|
||||
// File — одна физическая копия: исходник, результат конвертации и копия во
|
||||
// внешнем хранилище — три разные записи.
|
||||
type File struct {
|
||||
Id string
|
||||
Storage string
|
||||
Location string
|
||||
// FileName — имя, под которым файл лежит: у местной копии это имя, заданное
|
||||
// сервисом, у внешней — ключ объекта. Своего суффикса хранилище к заданному
|
||||
// имени не дописывает: суффикс появляется только у имён, которые оно строит
|
||||
// само из имени отправителя, а это умолчание не применяется.
|
||||
FileName string
|
||||
Size int64
|
||||
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
@@ -9,11 +9,11 @@ type TranscribeJob struct {
|
||||
State string
|
||||
Source string
|
||||
FileID *string
|
||||
IsError bool
|
||||
ErrorText *string
|
||||
AcquisitionID *string
|
||||
AcquireTime *time.Time
|
||||
DelayTime *time.Time
|
||||
Attempts int // Число попыток: растёт при захвате, обнуляется на шаге без отказа
|
||||
RecognitionOpID *string // ID операции распознавания в Yandex Cloud
|
||||
TranscriptionText *string // Результат распознавания
|
||||
TgChatId *int64 // Telegram: в какой чат отправить результат распознавания
|
||||
@@ -28,6 +28,11 @@ const (
|
||||
StateTranscribe = "transcribe"
|
||||
StateDone = "done"
|
||||
StateFailed = "failed"
|
||||
// StateDead — задача, которую мы повторяли и перестали. От `failed` она
|
||||
// отличается тем, чей это приговор: в `failed` задачу переводит шаг,
|
||||
// рассудивший об этой записи окончательно, а сюда она уходит без такого
|
||||
// суждения. Ни один шаг конвейера в неё не переводит сам.
|
||||
StateDead = "dead"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -43,6 +48,10 @@ func (j *TranscribeJob) MoveToState(state string) {
|
||||
j.DelayTime = nil
|
||||
j.AcquisitionID = nil
|
||||
j.AcquireTime = nil
|
||||
// Шаг, дошедший до перехода, завершился без отказа, а попытки считают
|
||||
// именно отказавшие: иначе задача, прошедшая конвейер целиком, накопила бы
|
||||
// их поштучно и умерла бы здоровой.
|
||||
j.Attempts = 0
|
||||
j.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
@@ -59,6 +68,25 @@ func (j *TranscribeJob) Done(transcriptionText string) {
|
||||
|
||||
func (j *TranscribeJob) Fail(errText string) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ type stubJobRepo struct {
|
||||
}
|
||||
|
||||
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) {
|
||||
return nil, errors.New("не зовётся этими проверками")
|
||||
@@ -37,7 +37,7 @@ func (r *stubJobRepo) FindAndAcquire(string, string, time.Time) (*entity.Transcr
|
||||
|
||||
func serviceWithRepo(repo contract.TranscriptJobRepository) *TranscribeService {
|
||||
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"}),
|
||||
})
|
||||
|
||||
_, err := svc.findJob("created", time.Minute)
|
||||
_, _, err := svc.findJob("created", time.Minute)
|
||||
|
||||
var noop *contract.NoopJobError
|
||||
if !errors.As(err, &noop) {
|
||||
@@ -66,7 +66,7 @@ func TestFindJobTranslatesWrappedNotFoundToNoop(t *testing.T) {
|
||||
func TestFindJobKeepsRealFailure(t *testing.T) {
|
||||
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
|
||||
if errors.As(err, &noop) {
|
||||
|
||||
@@ -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, "ни исходной копии, ни копии под результат не осталось")
|
||||
}
|
||||
@@ -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
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -18,6 +18,29 @@ import (
|
||||
|
||||
const (
|
||||
defaultAudioExt = "audio"
|
||||
|
||||
// Предел попыток. Число обратимо и живёт здесь одним местом; счётчик растёт
|
||||
// при захвате и обнуляется на шаге, завершившемся без отказа.
|
||||
maxAttempts = 5
|
||||
|
||||
// Пауза перед повтором отказавшей задачи растёт с числом попыток до
|
||||
// потолка. Ожидание чужой операции этой паузой не выражается — у него своя
|
||||
// задержка числом, и попытки оно не тратит.
|
||||
retryDelayBase = time.Second
|
||||
retryDelayCap = 5 * time.Minute
|
||||
|
||||
// Сроки захвата. Каждый не меньше того, что его шаг может занять на самом
|
||||
// длинном допустимом входе: расчётный потолок записи — шесть часов, и
|
||||
// конвертация такой записи идёт дольше часа по построению.
|
||||
conversionAcquireTimeout = 8 * time.Hour
|
||||
transcribeAcquireTimeout = 8 * time.Hour
|
||||
checkAcquireTimeout = time.Hour
|
||||
|
||||
// Задержки опроса операции распознавания. Числа, а не функция числа попыток:
|
||||
// счётчик на ожидании обнулён, и выведенная из него пауза выродилась бы в
|
||||
// своё наименьшее значение, учащая опрос платного сервиса.
|
||||
firstCheckDelay = 10 * time.Second
|
||||
nextCheckDelay = 5 * time.Second
|
||||
)
|
||||
|
||||
type TranscribeService struct {
|
||||
@@ -27,7 +50,6 @@ type TranscribeService struct {
|
||||
converter contract.AudioFileConverter
|
||||
recognizer contract.AudioRecognizer
|
||||
tgSender contract.TelegramMessageSender
|
||||
storagePath string
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
@@ -38,7 +60,6 @@ func NewTranscribeService(
|
||||
converter contract.AudioFileConverter,
|
||||
recognizer contract.AudioRecognizer,
|
||||
tgSender contract.TelegramMessageSender,
|
||||
storagePath string,
|
||||
logger *slog.Logger,
|
||||
) *TranscribeService {
|
||||
return &TranscribeService{
|
||||
@@ -48,162 +69,163 @@ func NewTranscribeService(
|
||||
converter: converter,
|
||||
recognizer: recognizer,
|
||||
tgSender: tgSender,
|
||||
storagePath: storagePath,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
Id: jobId,
|
||||
State: entity.StateCreated,
|
||||
Source: entity.SourceTelegram,
|
||||
TgChatId: &chatId,
|
||||
TgReplyMessageId: &replyMsgId,
|
||||
IsError: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
return s.createTranscribeJob(job, file, fileName)
|
||||
}
|
||||
|
||||
func (s *TranscribeService) CreateJobFromApi(file io.Reader, fileName string) (*entity.TranscribeJob, error) {
|
||||
jobId := uuid.NewString()
|
||||
now := time.Now()
|
||||
|
||||
job := &entity.TranscribeJob{
|
||||
Id: jobId,
|
||||
State: entity.StateCreated,
|
||||
Source: entity.SourceApi,
|
||||
IsError: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
return s.createTranscribeJob(job, file, fileName)
|
||||
}
|
||||
|
||||
func (s *TranscribeService) createTranscribeJob(job *entity.TranscribeJob, file io.Reader, fileName string) (*entity.TranscribeJob, error) {
|
||||
// Генерируем UUID для файла
|
||||
fileId := uuid.NewString()
|
||||
|
||||
// Определяем расширение файла
|
||||
ext := filepath.Ext(fileName)
|
||||
if ext == "" {
|
||||
ext = fmt.Sprintf(".%s", defaultAudioExt) // fallback если расширение не определено
|
||||
}
|
||||
|
||||
// Создаем путь для сохранения файла
|
||||
// Собственное имя записи: идентификатор с расширением. Имя, данное
|
||||
// отправителем, в хранилище не попадает — от него взято только расширение.
|
||||
fileId := uuid.NewString()
|
||||
storageFileName := fmt.Sprintf("%s%s", fileId, ext)
|
||||
storageFilePath := filepath.Join(s.storagePath, storageFileName)
|
||||
|
||||
// Имя, данное отправителем, в журнал не идёт: инвариант приватности.
|
||||
// Расширение из него уже стоит в собственном имени файла на диске.
|
||||
s.logger.Info("Creating transcribe job",
|
||||
"file_id", fileId,
|
||||
"storage_path", storageFilePath)
|
||||
|
||||
// Создаем файл на диске
|
||||
dst, err := os.Create(storageFilePath)
|
||||
// Содержимое ложится в рабочую копию потоком: в память запись целиком не
|
||||
// читается, расчётный потолок — шесть часов.
|
||||
work, err := s.fileRepo.Stage(ext, file)
|
||||
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
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
if err := dst.Close(); err != nil {
|
||||
s.logger.Error("Failed to close file", "error", err)
|
||||
size, err := work.Size()
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to measure uploaded file", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info, err := s.metaviewer.GetInfo(storageFilePath)
|
||||
fileRecord, err := s.fileRepo.CreateLocal(storageFileName, work)
|
||||
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
|
||||
}
|
||||
|
||||
s.logger.Info("File uploaded successfully",
|
||||
"file_id", fileId,
|
||||
"file_id", fileRecord.Id,
|
||||
"size", size,
|
||||
"duration_seconds", info.Seconds)
|
||||
|
||||
metrics.InputFileDurationHistogram.WithLabelValues().Observe(float64(info.Seconds))
|
||||
metrics.ObserveInputFileSize(ext, size)
|
||||
|
||||
// Создаем запись в таблице files
|
||||
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
|
||||
job.FileID = &fileRecord.Id
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get source file", "error", err, "file_id", *job.FileID)
|
||||
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), ".")
|
||||
if srcExt == "" {
|
||||
srcExt = defaultAudioExt
|
||||
}
|
||||
|
||||
s.logger.Info("Converting file",
|
||||
"job_id", job.Id,
|
||||
"src_path", srcFilePath,
|
||||
"dest_path", destFilePath,
|
||||
"src_format", srcExt)
|
||||
src, err := s.fileRepo.Localize(*job.FileID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to localize source file", "error", err, "file_id", *job.FileID)
|
||||
return err
|
||||
}
|
||||
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()
|
||||
err = s.converter.Convert(srcFilePath, destFilePath)
|
||||
err = s.converter.Convert(src.Path(), dest.Path())
|
||||
conversionDuration := time.Since(startTime)
|
||||
|
||||
// Записываем метрику времени конвертации
|
||||
@@ -214,43 +236,36 @@ func (s *TranscribeService) FindAndRunConversionJob() error {
|
||||
"error", err,
|
||||
"job_id", job.Id,
|
||||
"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 {
|
||||
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
|
||||
}
|
||||
|
||||
s.logger.Info("File conversion completed",
|
||||
"job_id", job.Id,
|
||||
"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
|
||||
destFileRecord := &entity.File{
|
||||
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)
|
||||
destFileName := fmt.Sprintf("%s%s", uuid.NewString(), ".ogg")
|
||||
destFileRecord, err := s.fileRepo.CreateLocal(destFileName, dest)
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
return err
|
||||
}
|
||||
@@ -259,36 +274,35 @@ func (s *TranscribeService) FindAndRunConversionJob() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TranscribeService) FindAndRunTranscribeJob() error {
|
||||
job, err := s.findJob(entity.StateConverted, time.Hour)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TranscribeService) transcribeJob(job *entity.TranscribeJob, holder string) error {
|
||||
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)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get file record", "error", err, "file_id", *job.FileID)
|
||||
return err
|
||||
}
|
||||
|
||||
filePath := filepath.Join(s.storagePath, fileRecord.FileName)
|
||||
|
||||
file, err := os.Open(filePath)
|
||||
content, err := s.fileRepo.Open(*job.FileID)
|
||||
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
|
||||
}
|
||||
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()
|
||||
destFileRecord := fileRecord.CopyWithStorage(destFileId, entity.StorageS3)
|
||||
|
||||
s.logger.Info("Starting recognition", "job_id", job.Id, "file_path", filePath)
|
||||
s.logger.Info("Starting recognition", "job_id", job.Id, "file_id", *job.FileID)
|
||||
|
||||
// Запускаем асинхронное распознавание
|
||||
operationID, err := s.recognizer.Recognize(file, destFileRecord.FileName)
|
||||
operationID, err := s.recognizer.Recognize(content, fileRecord.FileName)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to start recognition", "error", err, "job_id", job.Id)
|
||||
return err
|
||||
@@ -298,20 +312,19 @@ func (s *TranscribeService) FindAndRunTranscribeJob() error {
|
||||
"job_id", job.Id,
|
||||
"operation_id", operationID)
|
||||
|
||||
// Обновляем задачу с ID операции распознавания
|
||||
job.FileID = &destFileId
|
||||
job.RecognitionOpID = &operationID
|
||||
delayTime := time.Now().Add(10 * time.Second)
|
||||
job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
|
||||
|
||||
err = s.fileRepo.Create(destFileRecord)
|
||||
destFileRecord, err := s.fileRepo.CreateRemote(fileRecord.FileName, fileRecord.Size)
|
||||
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
|
||||
}
|
||||
|
||||
err = s.jobRepo.Save(job)
|
||||
if err != nil {
|
||||
// Обновляем задачу с ID операции распознавания
|
||||
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)
|
||||
return err
|
||||
}
|
||||
@@ -320,12 +333,7 @@ func (s *TranscribeService) FindAndRunTranscribeJob() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TranscribeService) FindAndRunTranscribeCheckJob() error {
|
||||
job, err := s.findJob(entity.StateTranscribe, 24*time.Hour)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TranscribeService) checkTranscribeJob(job *entity.TranscribeJob, holder string) error {
|
||||
if job.RecognitionOpID == nil {
|
||||
s.logger.Error("Recognition operation ID not found", "job_id", job.Id)
|
||||
return fmt.Errorf("recogniton opId not found for job: %s", job.Id)
|
||||
@@ -342,12 +350,13 @@ func (s *TranscribeService) FindAndRunTranscribeCheckJob() error {
|
||||
}
|
||||
|
||||
if recResult.IsInProgress() {
|
||||
// Операция еще не завершена, оставляем в статусе обработки
|
||||
// Операция ещё не завершена. Шаг отработал без отказа, поэтому задержка
|
||||
// здесь своя, числом, а число попыток обнуляется переходом: ожидание
|
||||
// чужой операции попытку не тратит.
|
||||
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)
|
||||
err := s.jobRepo.Save(job)
|
||||
if err != nil {
|
||||
if err := s.jobRepo.Save(job, holder); err != nil {
|
||||
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
|
||||
return err
|
||||
}
|
||||
@@ -360,7 +369,7 @@ func (s *TranscribeService) FindAndRunTranscribeCheckJob() error {
|
||||
"job_id", job.Id,
|
||||
"operation_id", opId,
|
||||
"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))
|
||||
|
||||
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()
|
||||
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 {
|
||||
// Признак узнаётся по смыслу: репозиторий вправе обернуть свой отказ
|
||||
// пояснением, и приведение типа от этого сломалось бы молча.
|
||||
var notFound *contract.JobNotFoundError
|
||||
if errors.As(err, ¬Found) {
|
||||
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)
|
||||
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)
|
||||
|
||||
// Сохраняем задачу в базу
|
||||
err := s.jobRepo.Save(job)
|
||||
if err != nil {
|
||||
if err := s.jobRepo.Save(job, holder); err != nil {
|
||||
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
|
||||
return fmt.Errorf("failed to save job: %w", err)
|
||||
}
|
||||
|
||||
// Отправляем распознанный текст обратно пользователю
|
||||
switch job.Source {
|
||||
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
|
||||
return s.send(job, transcriptionText)
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
// Сохраняем задачу в базу
|
||||
err := s.jobRepo.Save(job)
|
||||
if err != nil {
|
||||
if err := s.jobRepo.Save(job, holder); err != nil {
|
||||
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
|
||||
return fmt.Errorf("failed to save job: %w", err)
|
||||
}
|
||||
|
||||
// Отправляем текст об ошибке пользователю
|
||||
switch job.Source {
|
||||
case entity.SourceTelegram:
|
||||
errorMessage := fmt.Sprintf("При обработке задачи произошла ошибка: %s.\nПожалуйста, попробуйте еще раз.", humanErrorText)
|
||||
return s.send(job, errorMessage)
|
||||
}
|
||||
|
||||
// send отвечает отправителю там, откуда пришла запись, и отказ отправки
|
||||
// поднимает вверх: он принадлежит шагу.
|
||||
func (s *TranscribeService) send(job *entity.TranscribeJob, text string) error {
|
||||
if job.Source != entity.SourceTelegram {
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
errorMessage := fmt.Sprintf("При обработке задачи произошла ошибка: %s.\nПожалуйста, попробуйте еще раз.", humanErrorText)
|
||||
err := s.tgSender.Send(errorMessage, *job.TgChatId, job.TgReplyMessageId)
|
||||
if err != nil {
|
||||
if err := s.tgSender.Send(text, *job.TgChatId, job.TgReplyMessageId); err != nil {
|
||||
s.logger.Error("Failed to sent message to client", "job_id", job.Id)
|
||||
return fmt.Errorf("failed to sent message to client, job id: %s, err: %w", job.Id, err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -17,29 +16,17 @@ import (
|
||||
ffmpegconv "git.vakhrushev.me/av/transcriber/internal/adapter/converter/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/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/config"
|
||||
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
|
||||
tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg"
|
||||
"git.vakhrushev.me/av/transcriber/internal/controller/worker"
|
||||
"git.vakhrushev.me/av/transcriber/internal/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/mattn/go-sqlite3"
|
||||
"github.com/pressly/goose/v3"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"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() {
|
||||
@@ -68,35 +55,25 @@ func main() {
|
||||
logger.Warn("Warning: .env file not found, using system environment variables")
|
||||
}
|
||||
|
||||
// Создаем директории если они не существуют
|
||||
if err := os.MkdirAll(cfg.Storage.Path, 0750); err != nil {
|
||||
logger.Error("Failed to create file storage directory", "path", cfg.Storage.Path, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite3", cfg.Database.Path)
|
||||
// Хранилище поднимается библиотекой, а не её набором команд: разбор флагов
|
||||
// и мягкая остановка остаются нашими. Схему накатывает Serve — он гоняет
|
||||
// непринятые шаги прежде, чем поднять сервер.
|
||||
storage, err := pbrepo.New(cfg.Storage.DataDir)
|
||||
if err != nil {
|
||||
logger.Error("failed to open database", "error", err)
|
||||
logger.Error("Failed to open storage", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
logger.Error("failed to ping database", "error", err)
|
||||
os.Exit(1)
|
||||
defer func() {
|
||||
if err := storage.ResetBootstrapState(); err != nil {
|
||||
logger.Error("Failed to close storage", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
gq := goqu.New("sqlite3", db)
|
||||
|
||||
// Запускаем миграции
|
||||
if err := RunMigrations(db, logger); err != nil {
|
||||
logger.Error("Failed to run migrations", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
pbrepo.BindPanelRules(storage)
|
||||
|
||||
// Создаем репозитории
|
||||
fileRepo := sqlite.NewFileRepository(db, gq)
|
||||
jobRepo := sqlite.NewTranscriptJobRepository(db, gq)
|
||||
fileRepo := pbrepo.NewFileRepository(storage)
|
||||
jobRepo := pbrepo.NewTranscriptJobRepository(storage)
|
||||
|
||||
// Создаем адаптеры
|
||||
metaviewer := ffmpegmv.NewFfmpegMetaViewer()
|
||||
@@ -139,7 +116,6 @@ func main() {
|
||||
converter,
|
||||
recognizer,
|
||||
tgSender,
|
||||
cfg.Storage.Path,
|
||||
logger,
|
||||
)
|
||||
|
||||
@@ -193,49 +169,74 @@ func main() {
|
||||
}(w)
|
||||
}
|
||||
|
||||
// Создаем Gin middleware для логирования
|
||||
gin.SetMode(gin.DebugMode)
|
||||
router := gin.New()
|
||||
router.Use(sloggin.New(logger))
|
||||
router.Use(gin.Recovery())
|
||||
// Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
|
||||
// и второму серверу на нём взяться неоткуда.
|
||||
transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService, logger)
|
||||
|
||||
// Запускаем 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
|
||||
|
||||
// Настраиваем роуты только для создания задач и проверки статуса
|
||||
api := router.Group("/api")
|
||||
{
|
||||
api.POST("/audio", transcribeHandler.CreateTranscribeJob)
|
||||
api.GET("/status/:id", transcribeHandler.GetTranscribeJobStatus)
|
||||
// Журнал входящих запросов вернулся своим слоем: вместе с gin ушёл
|
||||
// `sloggin`, а хранилище пишет запросы в свою таблицу, которой в
|
||||
// журнале контейнера не видно. Поля — те, что просит конвенция.
|
||||
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
||||
start := time.Now()
|
||||
err := e.Next()
|
||||
|
||||
level := slog.LevelInfo
|
||||
if e.Request.URL.Path == "/health" || e.Request.URL.Path == "/metrics" {
|
||||
// Опрос здоровья и метрик идёт постоянно и полезного не несёт.
|
||||
level = slog.LevelDebug
|
||||
}
|
||||
|
||||
// Добавляем middleware для обработки больших файлов
|
||||
router.MaxMultipartMemory = 32 << 20 // 32 MiB
|
||||
logger.Log(e.Request.Context(), level, "Incoming request",
|
||||
"http.method", e.Request.Method,
|
||||
"http.route", e.Request.URL.Path,
|
||||
"http.status_code", e.Status(),
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
"transport", "http")
|
||||
|
||||
// Добавляем базовый роут для проверки работоспособности
|
||||
router.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
return err
|
||||
})
|
||||
|
||||
transcribeHandler.Register(se.Router)
|
||||
|
||||
se.Router.GET("/health", func(e *core.RequestEvent) error {
|
||||
return e.JSON(http.StatusOK, map[string]string{
|
||||
"status": "ok",
|
||||
"message": "Transcriber service is running",
|
||||
})
|
||||
})
|
||||
|
||||
// Добавляем эндпоинт для метрик Prometheus
|
||||
router.GET("/metrics", gin.WrapH(promhttp.Handler()))
|
||||
se.Router.GET("/metrics", func(e *core.RequestEvent) error {
|
||||
promhttp.Handler().ServeHTTP(e.Response, e.Request)
|
||||
return nil
|
||||
})
|
||||
|
||||
// Создаем HTTP сервер
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
|
||||
Handler: router,
|
||||
}
|
||||
return se.Next()
|
||||
})
|
||||
|
||||
// Запускаем HTTP сервер в отдельной горутине
|
||||
serveErr := make(chan error, 1)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
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)
|
||||
serveErr <- err
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -247,9 +248,13 @@ func main() {
|
||||
logger.Info("Workers: ConversionWorker, TranscribeWorker, CheckWorker")
|
||||
logger.Info("Press Ctrl+C to stop...")
|
||||
|
||||
// Ждем сигнал завершения
|
||||
<-sigChan
|
||||
// Ждем сигнал завершения либо отказ сервера
|
||||
select {
|
||||
case <-sigChan:
|
||||
logger.Info("Received shutdown signal, initiating graceful shutdown...")
|
||||
case <-serveErr:
|
||||
logger.Error("HTTP server stopped unexpectedly, shutting down")
|
||||
}
|
||||
|
||||
if tgController != nil {
|
||||
logger.Info("Shutting down Telegram bot...")
|
||||
@@ -261,12 +266,17 @@ func main() {
|
||||
defer shutdownCancel()
|
||||
|
||||
// Останавливаем HTTP сервер
|
||||
select {
|
||||
case srv := <-srvCh:
|
||||
logger.Info("Shutting down HTTP server...")
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Error("HTTP server forced to shutdown", "error", err)
|
||||
} else {
|
||||
logger.Info("HTTP server stopped gracefully")
|
||||
}
|
||||
default:
|
||||
logger.Info("HTTP server was not started, nothing to shut down")
|
||||
}
|
||||
|
||||
// Отменяем контекст для остановки воркеров
|
||||
cancel()
|
||||
@@ -288,19 +298,3 @@ func main() {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 Прогон поверхности: анонимный запрос к записям коллекций и к служебным
|
||||
разделам хранилища получает отказ
|
||||
@@ -10,7 +10,6 @@
|
||||
Telegram делит с ним общий шаг заведения задачи, но требований на него нет:
|
||||
требование, написанное без проверки, — предположение, а не норма. Первая задача,
|
||||
которая трогает поведение приёма из Telegram, дописывает его сюда.
|
||||
|
||||
## Requirements
|
||||
### Requirement: Приём записи по HTTP
|
||||
|
||||
@@ -25,13 +24,16 @@ MUST нести идентификатор задачи полем `job_id` и
|
||||
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
|
||||
пригодность содержимого узнаёт у источника метаданных.
|
||||
|
||||
Куда именно ложится принятая запись, приёму не принадлежит: раскладку выбирает
|
||||
хранилище, и нормирует её capability `storage`.
|
||||
|
||||
#### Scenario: Запись принята
|
||||
|
||||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
|
||||
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
|
||||
со значением `created`
|
||||
- **AND** содержимое записи целиком лежит в каталоге хранения одним файлом
|
||||
- **AND** содержимое записи целиком лежит в хранилище одним файлом
|
||||
|
||||
#### Scenario: Поля с записью нет
|
||||
|
||||
@@ -53,17 +55,28 @@ MUST нести идентификатор задачи полем `job_id` и
|
||||
своим приёму не подконтрольно.
|
||||
|
||||
Расширения в присланном имени нет — сервис MUST подставить `.audio`, чтобы у
|
||||
файла на диске расширение было всегда.
|
||||
файла в хранилище расширение было всегда.
|
||||
|
||||
Требование переживает смену раскладки. Умолчание хранилища, строящее имя из
|
||||
имени отправителя, MUST не применяться: имя отправителя в журнал не пишется по
|
||||
инварианту приватности, а изъятие из него кончается расширением — хвостом после
|
||||
последней точки.
|
||||
|
||||
#### Scenario: Расширение взято из имени отправителя
|
||||
|
||||
- **WHEN** программа шлёт запись с именем `test.mp3`
|
||||
- **THEN** файл в каталоге хранения имеет расширение `.mp3`
|
||||
- **THEN** имя файла в хранилище оканчивается на `.mp3`
|
||||
|
||||
#### Scenario: Имени без расширения назначено своё
|
||||
|
||||
- **WHEN** программа шлёт запись с именем `test` без расширения
|
||||
- **THEN** файл в каталоге хранения имеет расширение `.audio`
|
||||
- **THEN** имя файла в хранилище оканчивается на `.audio`
|
||||
|
||||
#### Scenario: Имя отправителя в хранилище не попало
|
||||
|
||||
- **WHEN** программа шлёт запись с именем `секретное-слово.mp3`
|
||||
- **THEN** имя файла в хранилище не содержит `секретное-слово`
|
||||
- **AND** путь к этому файлу не содержит его тоже
|
||||
|
||||
### Requirement: Отказ чтения метаданных
|
||||
|
||||
@@ -86,10 +99,10 @@ MUST нести идентификатор задачи полем `job_id` и
|
||||
личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи,
|
||||
откуда строку не убрать.
|
||||
|
||||
Расширение, взятое из этого имени, в журнале остаётся: оно стоит в собственном
|
||||
имени файла на диске, и по нему прослеживается путь записи. Что именно попадает в
|
||||
журнал ради прослеживаемости, нормирует требование ниже; наружу расширение
|
||||
выходит только приведённым к известному виду — этому отдано отдельное требование.
|
||||
Расширение, взятое из этого имени, в журнале остаётся собственным полем: по нему
|
||||
прослеживается путь записи. Что именно попадает в журнал ради прослеживаемости,
|
||||
нормирует требование ниже; наружу расширение выходит только приведённым к
|
||||
известному виду — этому отдано отдельное требование.
|
||||
|
||||
Сценарии судят приём по HTTP, потому что имя, данное отправителем, доходит до
|
||||
сервиса только оттуда: из Telegram приходит путь, выданный самим Telegram, а не
|
||||
@@ -119,8 +132,10 @@ MUST нести идентификатор задачи полем `job_id` и
|
||||
записи и её размер в байтах. По ним путь записи собирается отбором по журналу, и
|
||||
удаление имени отправителя прослеживаемости не отнимает.
|
||||
|
||||
Расширение засчитывается присутствием собственного имени файла в хранилище:
|
||||
отдельного поля под него приём не заводит.
|
||||
Расширение засчитывается собственным полем журнальной строки. Имя, под которым
|
||||
файл лёг в хранилище, приём MUST в журнал не писать: это имя — последняя часть
|
||||
ссылки на скачивание, и записанное вместе с идентификатором записи оно собирает
|
||||
ссылку целиком. Норму держит capability `storage`.
|
||||
|
||||
#### Scenario: Идентификатор, расширение и размер на месте
|
||||
|
||||
@@ -129,6 +144,12 @@ MUST нести идентификатор задачи полем `job_id` и
|
||||
- **THEN** журнал приёма несёт идентификатор заведённого файла, расширение
|
||||
принятой записи и её размер в байтах
|
||||
|
||||
#### Scenario: Имени файла в хранилище в журнале нет
|
||||
|
||||
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||
- **WHEN** программа шлёт `POST /api/audio` с записью
|
||||
- **THEN** имени, под которым файл лёг в хранилище, в журнале приёма нет
|
||||
|
||||
### Requirement: Метка метрики несёт только известное расширение
|
||||
|
||||
Сервис SHALL приводить расширение принятой записи к известному виду прежде, чем
|
||||
@@ -147,12 +168,12 @@ MUST нести идентификатор задачи полем `job_id` и
|
||||
Когда конвертацию нормируют своей capability, обязанность переезжает туда вместе
|
||||
с ней.
|
||||
|
||||
Имя файла на диске это требование не трогает: там расширение остаётся тем, каким
|
||||
пришло, — это уже нормировано требованием «Имя файла в хранилище».
|
||||
Имя файла в хранилище это требование не трогает: там расширение остаётся тем,
|
||||
каким пришло, — это уже нормировано требованием «Имя файла в хранилище».
|
||||
|
||||
Настоящий формат записи, попавшей в `other`, остаётся видимым в журнале: значение
|
||||
`other` в метке означает «расширение не из перечня», а само оно стоит в поле
|
||||
пути журнальной строки приёма и в поле формата строки конвертации.
|
||||
`other` в метке означает «расширение не из перечня», а само оно стоит полем
|
||||
журнальной строки приёма и полем формата строки конвертации.
|
||||
|
||||
#### Scenario: Незнакомое расширение наружу не выходит
|
||||
|
||||
@@ -160,7 +181,7 @@ MUST нести идентификатор задачи полем `job_id` и
|
||||
- **WHEN** программа шлёт запись с именем, чей хвост после последней точки не
|
||||
принадлежит перечню known-форматов
|
||||
- **THEN** метка метрики принимает значение `other`
|
||||
- **AND** файл в каталоге хранения сохраняет пришедшее расширение
|
||||
- **AND** имя файла в хранилище сохраняет пришедшее расширение
|
||||
|
||||
#### Scenario: Известное расширение идёт как есть
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ transcribe → done | failed`, захват задачи и срок его пр
|
||||
что такого поведения нет: оно живёт в коде, а требования на него не написаны,
|
||||
потому что требование без проверки — предположение, а не норма. Первая задача,
|
||||
которая трогает любое из перечисленного, дописывает его сюда.
|
||||
|
||||
## Requirements
|
||||
### Requirement: Пустой прогон воркера — не отказ
|
||||
|
||||
@@ -76,3 +75,188 @@ transcribe → done | failed`, захват задачи и срок его пр
|
||||
- **THEN** счётчик работы воркера растёт с пометкой успеха
|
||||
- **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** число попыток задачи не растёт
|
||||
|
||||
|
||||
@@ -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** приглашения завести владельца в журнале нет
|
||||
|
||||
Reference in New Issue
Block a user