From 01cc31d45fcd938bd1b72514bd323c3e27a48de7 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Wed, 12 Aug 2026 08:31:59 +0300 Subject: [PATCH] =?UTF-8?q?=D1=85=D1=80=D0=B0=D0=BD=D0=B8=D0=BB=D0=B8?= =?UTF-8?q?=D1=89=D0=B5,=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B5=D0=B9=20=D0=B8=20=D0=BE=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D1=8C=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=B5?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D1=8B=20=D0=BD=D0=B0=20=D0=B2=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B5=D0=BD=D0=BD=D1=83=D1=8E=20PocketBase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - записи, метаданные и файлы съехались под один каталог данных; появилась панель владельца, а gin, goqu, goose и требование CGO ушли - захват задачи стал одним запросом с RETURNING; заведены число попыток, состояние dead и нарастающая пауза вместо признака is_error - имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с идентификатором записи оно собирало бы ссылку на скачивание --- CLAUDE.md | 51 +- Dockerfile | 11 +- README.md | 43 +- config.dist.toml | 9 +- ...026-08-12-file-link-open-but-not-logged.md | 63 +++ ...R-2026-08-12-single-data-dir-config-key.md | 50 ++ docs/adr/README.md | 2 + docs/architecture.md | 71 ++- docs/conventions/database.md | 20 +- docs/conventions/logging.md | 9 +- docs/database.md | 166 ++++--- docs/research/README.md | 1 + docs/research/pocketbase-defaults.md | 98 ++++ docs/research/pocketbase.md | 4 + docs/review.md | 22 + docs/security.md | 59 ++- go.mod | 72 ++- go.sum | 217 ++++----- internal/adapter/recognizer/yandex/s3.go | 13 +- internal/adapter/repo/pocketbase/app.go | 56 +++ internal/adapter/repo/pocketbase/file_repo.go | 269 +++++++++++ .../adapter/repo/pocketbase/file_repo_test.go | 38 ++ .../adapter/repo/pocketbase/job_mapping.go | 203 ++++++++ .../adapter/repo/pocketbase/migrations.go | 122 +++++ internal/adapter/repo/pocketbase/panel.go | 36 ++ .../repo/pocketbase/transcript_job_repo.go | 147 ++++++ .../pocketbase/transcript_job_repo_test.go | 410 ++++++++++++++++ internal/adapter/repo/sqlite/file_repo.go | 56 --- .../repo/sqlite/transcript_job_repo.go | 230 --------- internal/config/config.go | 14 +- internal/contract/error.go | 12 + internal/contract/repository.go | 43 +- internal/controller/http/transcribe.go | 92 ++-- internal/controller/http/transcribe_test.go | 375 +++++++-------- internal/entity/file.go | 36 +- internal/entity/job.go | 32 +- internal/service/find_job_test.go | 10 +- internal/service/pipeline_test.go | 362 ++++++++++++++ internal/service/recognition_test.go | 267 ++++++++++ internal/service/transcribe.go | 449 +++++++++-------- main.go | 184 ++++--- migrations/001_create_files_table.sql | 11 - .../002_create_transcribe_jobs_table.sql | 24 - ...add_telegram_fields_to_transcribe_jobs.sql | 9 - .../.openspec.yaml | 2 + .../2026-08-12-pocketbase-storage/design.md | 454 ++++++++++++++++++ .../2026-08-12-pocketbase-storage/proposal.md | 60 +++ .../review/report.md | 215 +++++++++ .../specs/intake/spec.md | 166 +++++++ .../specs/pipeline/spec.md | 200 ++++++++ .../specs/storage/spec.md | 240 +++++++++ .../2026-08-12-pocketbase-storage/tasks.md | 196 ++++++++ openspec/specs/intake/spec.md | 53 +- openspec/specs/pipeline/spec.md | 186 ++++++- openspec/specs/storage/spec.md | 233 +++++++++ 55 files changed, 5238 insertions(+), 1235 deletions(-) create mode 100644 docs/adr/ADR-2026-08-12-file-link-open-but-not-logged.md create mode 100644 docs/adr/ADR-2026-08-12-single-data-dir-config-key.md create mode 100644 docs/research/pocketbase-defaults.md create mode 100644 internal/adapter/repo/pocketbase/app.go create mode 100644 internal/adapter/repo/pocketbase/file_repo.go create mode 100644 internal/adapter/repo/pocketbase/file_repo_test.go create mode 100644 internal/adapter/repo/pocketbase/job_mapping.go create mode 100644 internal/adapter/repo/pocketbase/migrations.go create mode 100644 internal/adapter/repo/pocketbase/panel.go create mode 100644 internal/adapter/repo/pocketbase/transcript_job_repo.go create mode 100644 internal/adapter/repo/pocketbase/transcript_job_repo_test.go delete mode 100644 internal/adapter/repo/sqlite/file_repo.go delete mode 100644 internal/adapter/repo/sqlite/transcript_job_repo.go create mode 100644 internal/service/pipeline_test.go create mode 100644 internal/service/recognition_test.go delete mode 100644 migrations/001_create_files_table.sql delete mode 100644 migrations/002_create_transcribe_jobs_table.sql delete mode 100644 migrations/003_add_telegram_fields_to_transcribe_jobs.sql create mode 100644 openspec/changes/archive/2026-08-12-pocketbase-storage/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-12-pocketbase-storage/design.md create mode 100644 openspec/changes/archive/2026-08-12-pocketbase-storage/proposal.md create mode 100644 openspec/changes/archive/2026-08-12-pocketbase-storage/review/report.md create mode 100644 openspec/changes/archive/2026-08-12-pocketbase-storage/specs/intake/spec.md create mode 100644 openspec/changes/archive/2026-08-12-pocketbase-storage/specs/pipeline/spec.md create mode 100644 openspec/changes/archive/2026-08-12-pocketbase-storage/specs/storage/spec.md create mode 100644 openspec/changes/archive/2026-08-12-pocketbase-storage/tasks.md create mode 100644 openspec/specs/storage/spec.md diff --git a/CLAUDE.md b/CLAUDE.md index fe79649..ca46a8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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, ротация секрета. - **Что считается сломанным** — новый красный шаг гейта, которого не было до diff --git a/Dockerfile b/Dockerfile index 692f1ff..8536a30 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 9e218a3..1b9b259 100644 --- a/README.md +++ b/README.md @@ -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`. Непринятые шаги накатываются при подъёме +хранилища, прежде чем стартуют воркеры и сервер. Применённый шаг не +переписывается: изменение — только новым файлом шага. Проверки перед коммитом — одной командой: diff --git a/config.dist.toml b/config.dist.toml index 6b3a373..3d7b513 100644 --- a/config.dist.toml +++ b/config.dist.toml @@ -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] diff --git a/docs/adr/ADR-2026-08-12-file-link-open-but-not-logged.md b/docs/adr/ADR-2026-08-12-file-link-open-but-not-logged.md new file mode 100644 index 0000000..1985cf1 --- /dev/null +++ b/docs/adr/ADR-2026-08-12-file-link-open-but-not-logged.md @@ -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/.ogg` читателю журнала +> бесполезен. После перевода имя файла в хранилище — это последняя часть ссылки +> `/api/files/...`, по которой запись скачивает кто угодно; строка журнала стала +> бы бессрочным ключом к чужому аудио. + +Путь был построен ревью и прогнан: отказ чтения из хранилища нёс ключ файла +целиком, строка уходила в журнал, а анонимный запрос по собранному адресу +отвечал `200` с телом записи. Второй путь шёл через отказ выгрузки в Object +Storage — тот несёт полный URL объекта. + +Отсюда вторая половина решения, без которой первая недопустима: **отказы +обрываются**. Наружу идёт свой текст с идентификатором записи, а чужая цепочка +`%w` — нет. В журнал приёма вместо имени идёт расширение собственным полем; +прослеживаемость от этого не страдает. + +Запись попадает в журнал как **намеренный отказ от очевидного подхода**: закрыть +файлы токеном предложат снова, и без записанной причины предложение выглядит +бесплатным. + +## Последствия + +- `+` ссылка работает без токена, и приёмка проверяется обычным запросом; будущее + приложение получает файл без отдельного механизма выдачи токенов. +- `+` изъятие из инварианта приватности не расширилось: в журнале по-прежнему + только расширение, а не имя. +- `−` ссылка, единожды утёкшая, работает бессрочно: отзыва у неё нет, а файлы не + удаляются вовсе. Утечка возможна не только журналом — любой будущий экран, + показывающий ссылку, наследует это свойство. +- `−` появилась норма, которую держит не построение, а внимание: всякий новый + отказ хранилища надо обрывать руками. Норму сторожат требование capability + `storage` и проверка журнала, но компилятор — нет. +- `−` диагностируемость отказов упала: обрывая цепочку, мы теряем причину. У + выгрузки в Object Storage это смягчено — сохраняется класс отказа SDK + (`AccessDenied`, `NoSuchBucket`), в котором адреса не бывает. +- Решение действует до разграничения доступа: задачи `oidc-login` и + `record-ownership` меняют условие, и тогда пометку стоит пересмотреть новой + записью. diff --git a/docs/adr/ADR-2026-08-12-single-data-dir-config-key.md b/docs/adr/ADR-2026-08-12-single-data-dir-config-key.md new file mode 100644 index 0000000..d7e1da7 --- /dev/null +++ b/docs/adr/ADR-2026-08-12-single-data-dir-config-key.md @@ -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`, а не на прежних путях. Данные при этом не + переносятся по решению задачи, так что цена нулевая ровно сейчас и была бы не + нулевой при переносе. diff --git a/docs/adr/README.md b/docs/adr/README.md index de2a35c..6245b15 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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) | | diff --git a/docs/architecture.md b/docs/architecture.md index 1df1578..c49c0be 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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). - - **Шаг конвейера идемпотентен по повтору.** Задача, брошенная на середине, достаётся снова по истечении срока захвата и проходит шаг заново. - **Ядро зависит от интерфейсов.** `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` | Правка задачи в панели проходит те же правила перехода, что и правка из кода | Конвейер: `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`. Коллектор был бы процессом, которого в diff --git a/docs/conventions/database.md b/docs/conventions/database.md index 4e3edeb..c9791b3 100644 --- a/docs/conventions/database.md +++ b/docs/conventions/database.md @@ -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`: + сравнение по неуникальному значению делает порядок обработки + невоспроизводимым. diff --git a/docs/conventions/logging.md b/docs/conventions/logging.md index c039ac3..060d9d4 100644 --- a/docs/conventions/logging.md +++ b/docs/conventions/logging.md @@ -202,8 +202,13 @@ Object Storage, скачивание файла из Telegram и опрос оп периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом `INFO` они не пишутся. -*Расхождение:* `sloggin` пишет все запросы одинаково, `/health` и `/metrics` -попадают в лог наравне с остальными. +Расхождения здесь больше нет: слой журналирования запросов свой, +`main.go`, хук `OnServe` — вместе с gin ушёл и `sloggin`. `/health` и `/metrics` +идут на `DEBUG`, то есть при боевом `INFO` не пишутся вовсе. + +Хранилище ведёт **свой** журнал запросов в собственной таблице, и он виден +владельцу в панели. Заменой потоку процесса он не служит: в журнал контейнера, +по которому разбирают отказы, эта таблица не попадает. ## Безопасность: что не логируем diff --git a/docs/database.md b/docs/database.md index 37708dc..711ebbd 100644 --- a/docs/database.md +++ b/docs/database.md @@ -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 остались только в **именах файлов**: имя, под которым запись ложится в +хранилище, задаёт сервис, и это `<расширение>`. + +**Время** — вид хранилища: строка `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/<коллекция>/<запись>/<имя>` рядом с файлом атрибутов. Имя задаёт + сервис — `<расширение>`; собственного суффикса хранилище не дописывает, + потому что умолчание, строящее имя из имени отправителя, не применяется. Ни + файлы, ни объекты в 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 тоже нет — ни одного. diff --git a/docs/research/README.md b/docs/research/README.md index f2d9471..c25295a 100644 --- a/docs/research/README.md +++ b/docs/research/README.md @@ -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 | diff --git a/docs/research/pocketbase-defaults.md b/docs/research/pocketbase-defaults.md new file mode 100644 index 0000000..184582c --- /dev/null +++ b/docs/research/pocketbase-defaults.md @@ -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). Потоковую укладку это подтверждает, предел + — нет. +- **Поведение под одновременной правкой панели и конвейера в бою.** Проверено + тестом на одной машине, не живой нагрузкой. diff --git a/docs/research/pocketbase.md b/docs/research/pocketbase.md index b7326c9..fa37a19 100644 --- a/docs/research/pocketbase.md +++ b/docs/research/pocketbase.md @@ -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 панели не видна. Путь она покажет строкой — прослушать и скачать запись по ней нельзя. Способа сослаться на файл, уже лежащий на диске мимо её каталога, нет. diff --git a/docs/review.md b/docs/review.md index 7578d9d..a8b9418 100644 --- a/docs/review.md +++ b/docs/review.md @@ -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`, абзац diff --git a/docs/security.md b/docs/security.md index c730fc7..5cf46c7 100644 --- a/docs/security.md +++ b/docs/security.md @@ -85,16 +85,27 @@ Telegram отправителю. Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда путь не предполагался. -- **Путь на диске** — `filepath.Join(cfg.Storage.Path, fileId + ext)`, где - `fileId` наш UUID, а **`ext` берётся из имени файла отправителя** через - `filepath.Ext`. Расширение в путь попадает без проверки списком; `filepath.Ext` - режет по последней точке и не пропускает разделитель каталогов, но это - единственное, что стоит между входом и именем файла. +- **Путь на диске** выбирает хранилище: + `data/storage/<коллекция>/<запись>/<имя>`. **Имя задаёт сервис** — + `<расширение>`, — а умолчание 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`. - **Перерасход денег на внешних сервисах.** Распознавание и языковая модель оплачиваются по факту; потолка на пользователя нет по тому же решению. diff --git a/go.mod b/go.mod index 96dae3b..60681d3 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index cd6dc41..e5922fb 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/adapter/recognizer/yandex/s3.go b/internal/adapter/recognizer/yandex/s3.go index a85ab89..1942482 100644 --- a/internal/adapter/recognizer/yandex/s3.go +++ b/internal/adapter/recognizer/yandex/s3.go @@ -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 diff --git a/internal/adapter/repo/pocketbase/app.go b/internal/adapter/repo/pocketbase/app.go new file mode 100644 index 0000000..ae6587d --- /dev/null +++ b/internal/adapter/repo/pocketbase/app.go @@ -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 +} diff --git a/internal/adapter/repo/pocketbase/file_repo.go b/internal/adapter/repo/pocketbase/file_repo.go new file mode 100644 index 0000000..78821fb --- /dev/null +++ b/internal/adapter/repo/pocketbase/file_repo.go @@ -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(), + } +} diff --git a/internal/adapter/repo/pocketbase/file_repo_test.go b/internal/adapter/repo/pocketbase/file_repo_test.go new file mode 100644 index 0000000..325a413 --- /dev/null +++ b/internal/adapter/repo/pocketbase/file_repo_test.go @@ -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, "объявленный потолок выше проверяемого размера") +} diff --git a/internal/adapter/repo/pocketbase/job_mapping.go b/internal/adapter/repo/pocketbase/job_mapping.go new file mode 100644 index 0000000..b987438 --- /dev/null +++ b/internal/adapter/repo/pocketbase/job_mapping.go @@ -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 +} diff --git a/internal/adapter/repo/pocketbase/migrations.go b/internal/adapter/repo/pocketbase/migrations.go new file mode 100644 index 0000000..a9d3272 --- /dev/null +++ b/internal/adapter/repo/pocketbase/migrations.go @@ -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 } diff --git a/internal/adapter/repo/pocketbase/panel.go b/internal/adapter/repo/pocketbase/panel.go new file mode 100644 index 0000000..e1b40aa --- /dev/null +++ b/internal/adapter/repo/pocketbase/panel.go @@ -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() + }) +} diff --git a/internal/adapter/repo/pocketbase/transcript_job_repo.go b/internal/adapter/repo/pocketbase/transcript_job_repo.go new file mode 100644 index 0000000..f788d48 --- /dev/null +++ b/internal/adapter/repo/pocketbase/transcript_job_repo.go @@ -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 +} diff --git a/internal/adapter/repo/pocketbase/transcript_job_repo_test.go b/internal/adapter/repo/pocketbase/transcript_job_repo_test.go new file mode 100644 index 0000000..ffcfac0 --- /dev/null +++ b/internal/adapter/repo/pocketbase/transcript_job_repo_test.go @@ -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, "правка владельца пережила сохранение шага") +} diff --git a/internal/adapter/repo/sqlite/file_repo.go b/internal/adapter/repo/sqlite/file_repo.go deleted file mode 100644 index 0727cdb..0000000 --- a/internal/adapter/repo/sqlite/file_repo.go +++ /dev/null @@ -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 -} diff --git a/internal/adapter/repo/sqlite/transcript_job_repo.go b/internal/adapter/repo/sqlite/transcript_job_repo.go deleted file mode 100644 index c7b170a..0000000 --- a/internal/adapter/repo/sqlite/transcript_job_repo.go +++ /dev/null @@ -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 -} diff --git a/internal/config/config.go b/internal/config/config.go index f12ec99..34e8f1c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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: "", diff --git a/internal/contract/error.go b/internal/contract/error.go index 12d0b01..68f2d83 100644 --- a/internal/contract/error.go +++ b/internal/contract/error.go @@ -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 } diff --git a/internal/contract/repository.go b/internal/contract/repository.go index 0115670..e0f387a 100644 --- a/internal/contract/repository.go +++ b/internal/contract/repository.go @@ -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) } diff --git a/internal/controller/http/transcribe.go b/internal/controller/http/transcribe.go index 08394b2..aa29cd6 100644 --- a/internal/controller/http/transcribe.go +++ b/internal/controller/http/transcribe.go @@ -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) -} diff --git a/internal/controller/http/transcribe_test.go b/internal/controller/http/transcribe_test.go index 31e048c..d0f51bb 100644 --- a/internal/controller/http/transcribe_test.go +++ b/internal/controller/http/transcribe_test.go @@ -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 - handler *TranscribeHandler - db *sql.DB - storageDir string - journal *journalBuffer + mux http.Handler + handler *TranscribeHandler + 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" + msgIntake = "Creating transcribe job" + // Отказ пишет доменная граница — приём, — а не транспорт: конвенция просит + // логировать ошибку один раз, и повторная запись транспорта снята. + 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"]) } diff --git a/internal/entity/file.go b/internal/entity/file.go index 20d445b..47e28a0 100644 --- a/internal/entity/file.go +++ b/internal/entity/file.go @@ -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 + Id 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(), - } -} diff --git a/internal/entity/job.go b/internal/entity/job.go index 86e074a..e498947 100644 --- a/internal/entity/job.go +++ b/internal/entity/job.go @@ -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 } diff --git a/internal/service/find_job_test.go b/internal/service/find_job_test.go index a463ca2..d749c0a 100644 --- a/internal/service/find_job_test.go +++ b/internal/service/find_job_test.go @@ -24,8 +24,8 @@ type stubJobRepo struct { err error } -func (r *stubJobRepo) Create(*entity.TranscribeJob) error { return nil } -func (r *stubJobRepo) Save(*entity.TranscribeJob) error { return nil } +func (r *stubJobRepo) Create(*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) { diff --git a/internal/service/pipeline_test.go b/internal/service/pipeline_test.go new file mode 100644 index 0000000..9f4bf21 --- /dev/null +++ b/internal/service/pipeline_test.go @@ -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, "ни исходной копии, ни копии под результат не осталось") +} diff --git a/internal/service/recognition_test.go b/internal/service/recognition_test.go new file mode 100644 index 0000000..a1e5c1e --- /dev/null +++ b/internal/service/recognition_test.go @@ -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, "отправителю ничего не отправлено") +} diff --git a/internal/service/transcribe.go b/internal/service/transcribe.go index c825295..0e2a6c7 100644 --- a/internal/service/transcribe.go +++ b/internal/service/transcribe.go @@ -5,7 +5,7 @@ import ( "fmt" "io" "log/slog" - "os" + "math" "path/filepath" "strings" "time" @@ -18,17 +18,39 @@ 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 { - jobRepo contract.TranscriptJobRepository - fileRepo contract.FileRepository - metaviewer contract.AudioMetaViewer - converter contract.AudioFileConverter - recognizer contract.AudioRecognizer - tgSender contract.TelegramMessageSender - storagePath string - logger *slog.Logger + jobRepo contract.TranscriptJobRepository + fileRepo contract.FileRepository + metaviewer contract.AudioMetaViewer + converter contract.AudioFileConverter + recognizer contract.AudioRecognizer + tgSender contract.TelegramMessageSender + logger *slog.Logger } func NewTranscribeService( @@ -38,172 +60,172 @@ func NewTranscribeService( converter contract.AudioFileConverter, recognizer contract.AudioRecognizer, tgSender contract.TelegramMessageSender, - storagePath string, logger *slog.Logger, ) *TranscribeService { return &TranscribeService{ - jobRepo: jobRepo, - fileRepo: fileRepo, - metaviewer: metaviewer, - converter: converter, - recognizer: recognizer, - tgSender: tgSender, - storagePath: storagePath, - logger: logger, + jobRepo: jobRepo, + fileRepo: fileRepo, + metaviewer: metaviewer, + converter: converter, + recognizer: recognizer, + tgSender: tgSender, + 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, + State: entity.StateCreated, + Source: entity.SourceApi, } 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: - 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) + return s.send(job, errorMessage) +} - errorMessage := fmt.Sprintf("При обработке задачи произошла ошибка: %s.\nПожалуйста, попробуйте еще раз.", humanErrorText) - err := s.tgSender.Send(errorMessage, *job.TgChatId, job.TgReplyMessageId) - if err != nil { - s.logger.Error("Failed to sent message to client", "job_id", job.Id) - return fmt.Errorf("failed to sent message to client, job id: %s, err: %w", job.Id, err) - } +// 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) + } + + 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) + } +} diff --git a/main.go b/main.go index eca7ed2..7ce8616 100644 --- a/main.go +++ b/main.go @@ -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() + defer func() { + if err := storage.ResetBootstrapState(); err != nil { + logger.Error("Failed to close storage", "error", err) + } + }() - if err := db.Ping(); err != nil { - logger.Error("failed to ping database", "error", err) - os.Exit(1) - } - - 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() - // Добавляем middleware для обработки больших файлов - router.MaxMultipartMemory = 32 << 20 // 32 MiB + level := slog.LevelInfo + if e.Request.URL.Path == "/health" || e.Request.URL.Path == "/metrics" { + // Опрос здоровья и метрик идёт постоянно и полезного не несёт. + level = slog.LevelDebug + } - // Добавляем базовый роут для проверки работоспособности - router.GET("/health", func(c *gin.Context) { - c.JSON(200, gin.H{ - "status": "ok", - "message": "Transcriber service is running", + 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") + + 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", + }) + }) + + se.Router.GET("/metrics", func(e *core.RequestEvent) error { + promhttp.Handler().ServeHTTP(e.Response, e.Request) + return nil + }) + + return se.Next() }) - // Добавляем эндпоинт для метрик Prometheus - router.GET("/metrics", gin.WrapH(promhttp.Handler())) - - // Создаем HTTP сервер - srv := &http.Server{ - Addr: fmt.Sprintf(":%d", cfg.Server.Port), - Handler: router, - } - // Запускаем 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 - logger.Info("Received shutdown signal, initiating graceful shutdown...") + // Ждем сигнал завершения либо отказ сервера + 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,11 +266,16 @@ func main() { defer shutdownCancel() // Останавливаем HTTP сервер - 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") + 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") } // Отменяем контекст для остановки воркеров @@ -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 -} diff --git a/migrations/001_create_files_table.sql b/migrations/001_create_files_table.sql deleted file mode 100644 index d6efc2b..0000000 --- a/migrations/001_create_files_table.sql +++ /dev/null @@ -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; diff --git a/migrations/002_create_transcribe_jobs_table.sql b/migrations/002_create_transcribe_jobs_table.sql deleted file mode 100644 index 4255e83..0000000 --- a/migrations/002_create_transcribe_jobs_table.sql +++ /dev/null @@ -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; diff --git a/migrations/003_add_telegram_fields_to_transcribe_jobs.sql b/migrations/003_add_telegram_fields_to_transcribe_jobs.sql deleted file mode 100644 index 3617f59..0000000 --- a/migrations/003_add_telegram_fields_to_transcribe_jobs.sql +++ /dev/null @@ -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; diff --git a/openspec/changes/archive/2026-08-12-pocketbase-storage/.openspec.yaml b/openspec/changes/archive/2026-08-12-pocketbase-storage/.openspec.yaml new file mode 100644 index 0000000..a8821c7 --- /dev/null +++ b/openspec/changes/archive/2026-08-12-pocketbase-storage/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-11 diff --git a/openspec/changes/archive/2026-08-12-pocketbase-storage/design.md b/openspec/changes/archive/2026-08-12-pocketbase-storage/design.md new file mode 100644 index 0000000..a6d86c5 --- /dev/null +++ b/openspec/changes/archive/2026-08-12-pocketbase-storage/design.md @@ -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/.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 запросов в сутки посчитаны, цена не + измерена; вопрос остаётся открытым. diff --git a/openspec/changes/archive/2026-08-12-pocketbase-storage/proposal.md b/openspec/changes/archive/2026-08-12-pocketbase-storage/proposal.md new file mode 100644 index 0000000..4aaf059 --- /dev/null +++ b/openspec/changes/archive/2026-08-12-pocketbase-storage/proposal.md @@ -0,0 +1,60 @@ +## Why + +Записи, их метаданные и сами файлы лежат порознь, и владелец сервиса не видит их +ничем, кроме консоли на сервере: чтобы посмотреть задачу или послушать запись, +он идёт руками в базу и в каталог на диске. Заодно принятая запись держится на +захвате из двух шагов подряд, между которыми задачу может перехватить соседний +воркер, а задача, падающая на каждой попытке, падает вечно и никем не считается. + +## What Changes + +- Записи, их метаданные и файлы съезжаются в одно хранилище, и владелец получает + панель, где видит задачу строкой, правит её и слушает саму запись. +- **BREAKING** Раскладка файлов на диске меняется: плоского каталога с именами по + идентификатору не остаётся, файл ложится в раскладку хранилища. Момент перехода + назначает человек. +- **BREAKING** Прежние данные не переносятся. Сервис начинает с чистого каталога + и заводит свою схему сам. +- Файл перестаёт отдаваться чтением с диска и отдаётся ссылкой, которую хранилище + строит по записи. +- Захват задачи воркером становится одним неделимым шагом: две задачи одному + состоянию больше не достаются. +- У задачи появляется число попыток. Задача, исчерпавшая их, переходит в + состояние «мертва»: из выборки исчезает, но остаётся видна владельцу и + возвращается в работу снятием состояния. +- Пауза перед повтором нарастает с номером попытки. +- Сборка перестаёт требовать CGO. +- Появляется секрет, которого не было: пароль владельца от панели. В + конфигурации он не лежит. + +## Capabilities + +### New Capabilities + +- `storage`: где живут запись, её метаданные и её файл; как файл попадает в + хранилище и как отдаётся обратно; что владелец видит и правит в панели; с + каким состоянием сервис поднимается на чистом каталоге. + +### Modified Capabilities + +- `pipeline`: захват задачи становится неделимым; появляются число попыток, + нарастающая пауза и состояние «мертва» вместо признака ошибки, исключающего + задачу навсегда; описывается срок протухания захвата. +- `intake`: принятая запись уезжает в хранилище, а не в плоский каталог; + требование «имя отправителя в хранилище не попадает» остаётся в силе и в новой + раскладке. + +## Impact + +- Хранилище задач и файлов целиком: прежний слой запросов, построитель запросов и + механизм миграций уходят вместе с каталогом `migrations/`. +- Договор между ядром и хранилищем: интерфейсы репозиториев задач и файлов. +- Состав полей задачи: прибавляется число попыток, признак ошибки уступает место + состоянию в перечне состояний. +- Ключи конфигурации: путь к базе и путь к каталогу файлов заменяются одним + каталогом данных. +- Приём по HTTP и приём из Telegram — в части того, куда кладётся принятая + запись. +- Сборка образа: набор зависимостей меняется, требование CGO уходит. +- Документы: схема хранилища, инварианты и запреты с путями, модель угроз в части + того, из чего строятся пути. diff --git a/openspec/changes/archive/2026-08-12-pocketbase-storage/review/report.md b/openspec/changes/archive/2026-08-12-pocketbase-storage/review/report.md new file mode 100644 index 0000000..6abf081 --- /dev/null +++ b/openspec/changes/archive/2026-08-12-pocketbase-storage/review/report.md @@ -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. Оно стояло на посылке о суффиксе имени, которой нет: имя в хранилище и +есть `<расширение>`, значит утверждение `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/`, затем шаг + сохраняет снимок. Итог: `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. diff --git a/openspec/changes/archive/2026-08-12-pocketbase-storage/specs/intake/spec.md b/openspec/changes/archive/2026-08-12-pocketbase-storage/specs/intake/spec.md new file mode 100644 index 0000000..f362665 --- /dev/null +++ b/openspec/changes/archive/2026-08-12-pocketbase-storage/specs/intake/spec.md @@ -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` diff --git a/openspec/changes/archive/2026-08-12-pocketbase-storage/specs/pipeline/spec.md b/openspec/changes/archive/2026-08-12-pocketbase-storage/specs/pipeline/spec.md new file mode 100644 index 0000000..eab2ee1 --- /dev/null +++ b/openspec/changes/archive/2026-08-12-pocketbase-storage/specs/pipeline/spec.md @@ -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** число попыток задачи не растёт diff --git a/openspec/changes/archive/2026-08-12-pocketbase-storage/specs/storage/spec.md b/openspec/changes/archive/2026-08-12-pocketbase-storage/specs/storage/spec.md new file mode 100644 index 0000000..f89724e --- /dev/null +++ b/openspec/changes/archive/2026-08-12-pocketbase-storage/specs/storage/spec.md @@ -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** приглашения завести владельца в журнале нет diff --git a/openspec/changes/archive/2026-08-12-pocketbase-storage/tasks.md b/openspec/changes/archive/2026-08-12-pocketbase-storage/tasks.md new file mode 100644 index 0000000..6bdd385 --- /dev/null +++ b/openspec/changes/archive/2026-08-12-pocketbase-storage/tasks.md @@ -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 Прогон поверхности: анонимный запрос к записям коллекций и к служебным + разделам хранилища получает отказ diff --git a/openspec/specs/intake/spec.md b/openspec/specs/intake/spec.md index e0b5fac..b84a969 100644 --- a/openspec/specs/intake/spec.md +++ b/openspec/specs/intake/spec.md @@ -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: Известное расширение идёт как есть diff --git a/openspec/specs/pipeline/spec.md b/openspec/specs/pipeline/spec.md index 1210680..2c5034a 100644 --- a/openspec/specs/pipeline/spec.md +++ b/openspec/specs/pipeline/spec.md @@ -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** число попыток задачи не растёт + diff --git a/openspec/specs/storage/spec.md b/openspec/specs/storage/spec.md new file mode 100644 index 0000000..1010f22 --- /dev/null +++ b/openspec/specs/storage/spec.md @@ -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** приглашения завести владельца в журнале нет +