хранилище, файлы записей и очередь переведены на встроенную PocketBase
- записи, метаданные и файлы съехались под один каталог данных; появилась панель владельца, а gin, goqu, goose и требование CGO ушли - захват задачи стал одним запросом с RETURNING; заведены число попыток, состояние dead и нарастающая пауза вместо признака is_error - имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с идентификатором записи оно собирало бы ссылку на скачивание
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
// Package pocketbase — хранилище задач и файлов поверх встроенной PocketBase.
|
||||
//
|
||||
// Приложение поднимается библиотекой, а не её набором команд: разбор флагов и
|
||||
// мягкая остановка остаются нашими, а ключ `-c config.toml` — объявленный
|
||||
// контракт запуска.
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
pb "github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// Имена коллекций. Они же — часть пути к файлу в раскладке хранилища и часть
|
||||
// адреса ссылки на него, поэтому меняются только новым шагом схемы.
|
||||
const (
|
||||
FilesCollection = "files"
|
||||
JobsCollection = "transcribe_jobs"
|
||||
)
|
||||
|
||||
// New создаёт приложение хранилища на заданном каталоге данных и приводит его в
|
||||
// рабочее состояние: открывает базу, читает настройки и накатывает непринятые
|
||||
// шаги схемы.
|
||||
//
|
||||
// Схема накатывается **здесь**, а не оставляется серверу, хотя тот и гоняет
|
||||
// непринятые шаги сам. Причина в порядке: воркеры стартуют раньше сервера, и на
|
||||
// чистом каталоге их первые опросы приходились бы на несуществующую таблицу —
|
||||
// отказ в журнале и в счётчике на каждую секунду до конца накатки.
|
||||
func New(dataDir string) (*pb.PocketBase, error) {
|
||||
app := pb.NewWithConfig(pb.Config{
|
||||
DefaultDataDir: dataDir,
|
||||
HideStartBanner: true,
|
||||
})
|
||||
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
return nil, fmt.Errorf("failed to bootstrap storage: %w", err)
|
||||
}
|
||||
|
||||
if err := app.RunAllMigrations(); err != nil {
|
||||
return nil, fmt.Errorf("failed to apply storage schema: %w", err)
|
||||
}
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
// MustFindCollection достаёт коллекцию по имени. Отсутствие коллекции здесь —
|
||||
// не отказ окружения, а несделанный шаг схемы: сервис до этой точки не доходит,
|
||||
// потому что Serve накатывает схему прежде, чем поднять сервер.
|
||||
func findCollection(app core.App, name string) (*core.Collection, error) {
|
||||
collection, err := app.FindCollectionByNameOrId(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find collection %s: %w", name, err)
|
||||
}
|
||||
return collection, nil
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// workFile — рабочая копия файла на диске. Живёт во временном каталоге
|
||||
// системы, а не в каталоге данных: последний смонтирован на сервере, и
|
||||
// временному там не место.
|
||||
type workFile struct {
|
||||
path string
|
||||
}
|
||||
|
||||
func (w *workFile) Path() string { return w.path }
|
||||
|
||||
func (w *workFile) Size() (int64, error) {
|
||||
info, err := os.Stat(w.path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to stat work file: %w", err)
|
||||
}
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
// Close убирает копию. Отсутствие файла отказом не считается: шаг мог не дойти
|
||||
// до его создания, и повторный Close тоже законен.
|
||||
func (w *workFile) Close() error {
|
||||
if err := os.Remove(w.path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to remove work file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type FileRepository struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
func NewFileRepository(app core.App) *FileRepository {
|
||||
return &FileRepository{app: app}
|
||||
}
|
||||
|
||||
// newWorkFile заводит пустую копию во временном каталоге. Расширение сохраняется
|
||||
// в имени: `ffprobe` и `ffmpeg` по нему выбирают разбор.
|
||||
func newWorkFile(ext string) (*workFile, error) {
|
||||
f, err := os.CreateTemp("", "transcriber-*"+ext)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create work file: %w", err)
|
||||
}
|
||||
path := f.Name()
|
||||
if err := f.Close(); err != nil {
|
||||
_ = os.Remove(path)
|
||||
return nil, fmt.Errorf("failed to close work file: %w", err)
|
||||
}
|
||||
return &workFile{path: path}, nil
|
||||
}
|
||||
|
||||
func (repo *FileRepository) StageEmpty(ext string) (contract.WorkFile, error) {
|
||||
return newWorkFile(ext)
|
||||
}
|
||||
|
||||
func (repo *FileRepository) Stage(ext string, content io.Reader) (contract.WorkFile, error) {
|
||||
work, err := newWorkFile(ext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := writeTo(work.path, content); err != nil {
|
||||
// Отказ уборки не подменяет отказ записи, но и не теряется.
|
||||
return nil, errors.Join(err, work.Close())
|
||||
}
|
||||
|
||||
return work, nil
|
||||
}
|
||||
|
||||
func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
|
||||
record, err := repo.app.FindRecordById(FilesCollection, fileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
|
||||
}
|
||||
|
||||
name := firstFileName(record)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("file %s has no content in storage", fileID)
|
||||
}
|
||||
|
||||
work, err := newWorkFile(filepath.Ext(name))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
src, err := repo.openStored(record, name)
|
||||
if err != nil {
|
||||
return nil, errors.Join(err, work.Close())
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
if err := writeTo(work.path, src); err != nil {
|
||||
return nil, errors.Join(err, work.Close())
|
||||
}
|
||||
|
||||
return work, nil
|
||||
}
|
||||
|
||||
// CreateLocal кладёт рабочую копию в хранилище. Имя задаём мы: умолчание
|
||||
// библиотеки строит его из имени, данного отправителем, а имя отправителя в
|
||||
// хранилище не попадает — путь к файлу читается в журнале, и инвариант
|
||||
// приватности этого не допускает. Свой суффикс хранилище допишет само.
|
||||
func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*entity.File, error) {
|
||||
collection, err := findCollection(repo.app, FilesCollection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stored, err := filesystem.NewFileFromPath(work.Path())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read work file: %w", err)
|
||||
}
|
||||
stored.Name = name
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
record.Set("file", stored)
|
||||
record.Set("location", entity.LocationLocal)
|
||||
record.Set("size", stored.Size)
|
||||
|
||||
if err := repo.app.Save(record); err != nil {
|
||||
// Отказ укладки называет имя файла — то самое, из которого строится
|
||||
// ссылка на скачивание. В цепочку оно не идёт по той же причине, что и
|
||||
// ключ при чтении.
|
||||
return nil, errors.New("failed to store file")
|
||||
}
|
||||
|
||||
return recordToFile(record), nil
|
||||
}
|
||||
|
||||
func (repo *FileRepository) CreateRemote(objectKey string, size int64) (*entity.File, error) {
|
||||
collection, err := findCollection(repo.app, FilesCollection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
record.Set("location", entity.LocationS3)
|
||||
record.Set("object_key", objectKey)
|
||||
record.Set("size", size)
|
||||
|
||||
if err := repo.app.Save(record); err != nil {
|
||||
return nil, fmt.Errorf("failed to store remote file record: %w", err)
|
||||
}
|
||||
|
||||
return recordToFile(record), nil
|
||||
}
|
||||
|
||||
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
|
||||
record, err := repo.app.FindRecordById(FilesCollection, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get file: %w", err)
|
||||
}
|
||||
return recordToFile(record), nil
|
||||
}
|
||||
|
||||
func (repo *FileRepository) Open(fileID string) (io.ReadCloser, error) {
|
||||
record, err := repo.app.FindRecordById(FilesCollection, fileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
|
||||
}
|
||||
|
||||
name := firstFileName(record)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("file %s has no content in storage", fileID)
|
||||
}
|
||||
|
||||
return repo.openStored(record, name)
|
||||
}
|
||||
|
||||
// openStored открывает содержимое файла в хранилище потоком.
|
||||
func (repo *FileRepository) openStored(record *core.Record, name string) (io.ReadCloser, error) {
|
||||
fsys, err := repo.app.NewFilesystem()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open storage filesystem: %w", err)
|
||||
}
|
||||
|
||||
reader, err := fsys.GetReader(record.BaseFilesPath() + "/" + name)
|
||||
if err != nil {
|
||||
// Отказ хранилища несёт ключ файла целиком, а ключ — последняя часть
|
||||
// ссылки `/api/files/...`, по которой запись скачивают. Наружу отдаётся
|
||||
// идентификатор записи, и только он: цепочка `%w` уехала бы в журнал и
|
||||
// стала бы там бессрочным ключом к чужому аудио.
|
||||
return nil, errors.Join(
|
||||
fmt.Errorf("failed to read stored file of record %s", record.Id),
|
||||
fsys.Close(),
|
||||
)
|
||||
}
|
||||
|
||||
return &storedReader{reader: reader, fsys: fsys}, nil
|
||||
}
|
||||
|
||||
// storedReader держит открытой файловую систему хранилища на всё время чтения:
|
||||
// закрытая раньше времени, она обрывает поток на середине записи.
|
||||
type storedReader struct {
|
||||
reader io.ReadCloser
|
||||
fsys io.Closer
|
||||
}
|
||||
|
||||
func (r *storedReader) Read(p []byte) (int, error) { return r.reader.Read(p) }
|
||||
|
||||
func (r *storedReader) Close() error {
|
||||
readerErr := r.reader.Close()
|
||||
fsysErr := r.fsys.Close()
|
||||
switch {
|
||||
case readerErr != nil && fsysErr != nil:
|
||||
return errors.New("failed to close stored file and its filesystem")
|
||||
case readerErr != nil:
|
||||
return errors.New("failed to close stored file")
|
||||
default:
|
||||
return fsysErr
|
||||
}
|
||||
}
|
||||
|
||||
// writeTo переливает содержимое в файл потоком. В память запись целиком не
|
||||
// читается: расчётный потолок — шесть часов.
|
||||
func writeTo(path string, content io.Reader) error {
|
||||
dst, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open work file: %w", err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(dst, content); err != nil {
|
||||
_ = dst.Close()
|
||||
return fmt.Errorf("failed to write work file: %w", err)
|
||||
}
|
||||
|
||||
if err := dst.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close work file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstFileName(record *core.Record) string {
|
||||
names := record.GetStringSlice("file")
|
||||
if len(names) == 0 {
|
||||
return ""
|
||||
}
|
||||
return names[0]
|
||||
}
|
||||
|
||||
func recordToFile(record *core.Record) *entity.File {
|
||||
name := firstFileName(record)
|
||||
if name == "" {
|
||||
name = record.GetString("object_key")
|
||||
}
|
||||
|
||||
return &entity.File{
|
||||
Id: record.Id,
|
||||
Location: record.GetString("location"),
|
||||
FileName: name,
|
||||
Size: int64(record.GetInt("size")),
|
||||
CreatedAt: record.GetDateTime("created").Time(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Потолок размера у поля файла задан числом, а не нулём: нулём библиотека читает
|
||||
// собственное умолчание в 5 МиБ, и на нём отвергалось бы всё длиннее примерно
|
||||
// пяти минут — то есть штатная запись сервиса. Проверка судит запись, которая
|
||||
// заведомо больше этого умолчания: обновление библиотеки, вернувшее умолчание,
|
||||
// иначе прошло бы молча.
|
||||
func TestCreateLocal_AcceptsRecordLargerThanLibraryDefault(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
repo := NewFileRepository(app)
|
||||
|
||||
const libraryDefault = 5 << 20
|
||||
|
||||
// Ровно на байт больше умолчания: проверка судит границу, а не пропускную
|
||||
// способность — лишние мегабайты стоили бы секунд на каждом прогоне.
|
||||
work, err := repo.Stage(".mp3", strings.NewReader(strings.Repeat("a", libraryDefault+1)))
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, work.Close()) }()
|
||||
|
||||
size, err := work.Size()
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, size, int64(libraryDefault), "запись заведомо больше умолчания библиотеки")
|
||||
|
||||
file, err := repo.CreateLocal("big.mp3", work)
|
||||
require.NoError(t, err, "запись длиннее умолчания библиотеки ложится в хранилище")
|
||||
assert.Equal(t, size, file.Size)
|
||||
assert.Greater(t, entity.MaxRecordSize, size, "объявленный потолок выше проверяемого размера")
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Отображение задачи в запись коллекции и обратно живёт одним местом. Прежде
|
||||
// список колонок был переписан четырежды — в каждом запросе своего слоя, — и
|
||||
// расхождение проявлялось как потерянное при сохранении поле.
|
||||
|
||||
// applyOwnedByPipeline кладёт в запись только те поля, которыми распоряжается
|
||||
// конвейер. Поля, которые он не меняет никогда — куда отвечать отправителю и
|
||||
// каким входом пришла запись, — не трогаются вовсе.
|
||||
//
|
||||
// Разрез нужен потому, что шаг держит задачу снимком с момента захвата и до
|
||||
// своего сохранения, а это до восьми часов. Всё, что владелец правил в панели за
|
||||
// это время, безусловная запись снимка стёрла бы молча: ни строки в журнале, ни
|
||||
// отказа в панели — владелец видел бы успешное сохранение и был бы уверен, что
|
||||
// правка на месте.
|
||||
func applyOwnedByPipeline(record *core.Record, job *entity.TranscribeJob) {
|
||||
record.Set("state", job.State)
|
||||
record.Set("file", derefString(job.FileID))
|
||||
record.Set("error_text", derefString(job.ErrorText))
|
||||
record.Set("acquisition_id", derefString(job.AcquisitionID))
|
||||
record.Set("acquire_time", dateOrEmpty(job.AcquireTime))
|
||||
record.Set("delay_time", dateOrEmpty(job.DelayTime))
|
||||
record.Set("attempts", job.Attempts)
|
||||
record.Set("recognition_op_id", derefString(job.RecognitionOpID))
|
||||
record.Set("transcription_text", derefString(job.TranscriptionText))
|
||||
}
|
||||
|
||||
// applyToRecord кладёт задачу в запись целиком — это заведение, и спорить за
|
||||
// поля здесь не с кем.
|
||||
func applyToRecord(record *core.Record, job *entity.TranscribeJob) {
|
||||
applyOwnedByPipeline(record, job)
|
||||
record.Set("source", job.Source)
|
||||
record.Set("tg_chat_id", derefInt64(job.TgChatId))
|
||||
record.Set("tg_reply_message_id", derefInt(job.TgReplyMessageId))
|
||||
}
|
||||
|
||||
func recordToJob(record *core.Record) *entity.TranscribeJob {
|
||||
return &entity.TranscribeJob{
|
||||
Id: record.Id,
|
||||
State: record.GetString("state"),
|
||||
Source: record.GetString("source"),
|
||||
FileID: nilIfEmpty(record.GetString("file")),
|
||||
ErrorText: nilIfEmpty(record.GetString("error_text")),
|
||||
AcquisitionID: nilIfEmpty(record.GetString("acquisition_id")),
|
||||
AcquireTime: timeOrNil(record.GetDateTime("acquire_time")),
|
||||
DelayTime: timeOrNil(record.GetDateTime("delay_time")),
|
||||
Attempts: record.GetInt("attempts"),
|
||||
RecognitionOpID: nilIfEmpty(record.GetString("recognition_op_id")),
|
||||
TranscriptionText: nilIfEmpty(record.GetString("transcription_text")),
|
||||
TgChatId: nilIfZero64(int64(record.GetInt("tg_chat_id"))),
|
||||
TgReplyMessageId: nilIfZeroInt(record.GetInt("tg_reply_message_id")),
|
||||
CreatedAt: record.GetDateTime("created").Time(),
|
||||
UpdatedAt: record.GetDateTime("updated").Time(),
|
||||
}
|
||||
}
|
||||
|
||||
// acquiredRow — задача, прочитанная сырым запросом захвата. Колонки читаются
|
||||
// именно так, потому что запрос идёт мимо записей коллекции; связь с их
|
||||
// перечнем держит константа acquireColumns и тест захвата, читающий задачу
|
||||
// целиком.
|
||||
type acquiredRow struct {
|
||||
Id string `db:"id"`
|
||||
State string `db:"state"`
|
||||
Source string `db:"source"`
|
||||
FileID sql.NullString `db:"file"`
|
||||
ErrorText sql.NullString `db:"error_text"`
|
||||
AcquisitionID sql.NullString `db:"acquisition_id"`
|
||||
AcquireTime sql.NullString `db:"acquire_time"`
|
||||
DelayTime sql.NullString `db:"delay_time"`
|
||||
Attempts int `db:"attempts"`
|
||||
RecognitionOpID sql.NullString `db:"recognition_op_id"`
|
||||
TranscriptionText sql.NullString `db:"transcription_text"`
|
||||
TgChatId sql.NullInt64 `db:"tg_chat_id"`
|
||||
TgReplyMessageId sql.NullInt64 `db:"tg_reply_message_id"`
|
||||
Created sql.NullString `db:"created"`
|
||||
Updated sql.NullString `db:"updated"`
|
||||
}
|
||||
|
||||
func (r *acquiredRow) toJob() *entity.TranscribeJob {
|
||||
job := &entity.TranscribeJob{
|
||||
Id: r.Id,
|
||||
State: r.State,
|
||||
Source: r.Source,
|
||||
FileID: nullToPtr(r.FileID),
|
||||
ErrorText: nullToPtr(r.ErrorText),
|
||||
AcquisitionID: nullToPtr(r.AcquisitionID),
|
||||
AcquireTime: parseTimeOrNil(r.AcquireTime),
|
||||
DelayTime: parseTimeOrNil(r.DelayTime),
|
||||
Attempts: r.Attempts,
|
||||
RecognitionOpID: nullToPtr(r.RecognitionOpID),
|
||||
TranscriptionText: nullToPtr(r.TranscriptionText),
|
||||
}
|
||||
|
||||
if r.TgChatId.Valid && r.TgChatId.Int64 != 0 {
|
||||
chatId := r.TgChatId.Int64
|
||||
job.TgChatId = &chatId
|
||||
}
|
||||
if r.TgReplyMessageId.Valid && r.TgReplyMessageId.Int64 != 0 {
|
||||
msgId := int(r.TgReplyMessageId.Int64)
|
||||
job.TgReplyMessageId = &msgId
|
||||
}
|
||||
if created := parseTimeOrNil(r.Created); created != nil {
|
||||
job.CreatedAt = *created
|
||||
}
|
||||
if updated := parseTimeOrNil(r.Updated); updated != nil {
|
||||
job.UpdatedAt = *updated
|
||||
}
|
||||
|
||||
return job
|
||||
}
|
||||
|
||||
func derefString(v *string) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
func derefInt64(v *int64) int64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
func derefInt(v *int) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// dateOrEmpty отдаёт пустое значение вместо нулевой даты: пустая колонка даты в
|
||||
// хранилище это пустая строка, и она же значит «времени нет».
|
||||
func dateOrEmpty(v *time.Time) any {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
date, err := types.ParseDateTime(*v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func nilIfEmpty(v string) *string {
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
func timeOrNil(v types.DateTime) *time.Time {
|
||||
if v.IsZero() {
|
||||
return nil
|
||||
}
|
||||
t := v.Time()
|
||||
return &t
|
||||
}
|
||||
|
||||
func nilIfZero64(v int64) *int64 {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
func nilIfZeroInt(v int) *int {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
func nullToPtr(v sql.NullString) *string {
|
||||
if !v.Valid || v.String == "" {
|
||||
return nil
|
||||
}
|
||||
s := v.String
|
||||
return &s
|
||||
}
|
||||
|
||||
func parseTimeOrNil(v sql.NullString) *time.Time {
|
||||
if !v.Valid || v.String == "" {
|
||||
return nil
|
||||
}
|
||||
date, err := types.ParseDateTime(v.String)
|
||||
if err != nil || date.IsZero() {
|
||||
return nil
|
||||
}
|
||||
t := date.Time()
|
||||
return &t
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/migrations"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Схема заводится версионированными шагами, и применённый шаг не переписывается
|
||||
// — только новым шагом. Инвариант проекта перенесён дословно: хранилище считает
|
||||
// применённое по имени файла шага.
|
||||
//
|
||||
// Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его
|
||||
// `apis.Serve` прежде, чем поднять сервер.
|
||||
func init() {
|
||||
migrations.Register(up202608110001, down202608110001, "202608110001_init.go")
|
||||
}
|
||||
|
||||
func up202608110001(app core.App) error {
|
||||
files := core.NewBaseCollection(FilesCollection)
|
||||
files.Fields.Add(
|
||||
// Сам файл. Защищённым поле не помечено намеренно: право прочитать
|
||||
// запись даёт знание её идентификатора, и файл встаёт вровень с опросом
|
||||
// готовности задачи, а не ниже.
|
||||
//
|
||||
// Потолок задан **числом**: нулём библиотека читает не «без предела», а
|
||||
// своё умолчание в 5 МиБ, и на нём отваливалось бы всё длиннее пяти
|
||||
// минут. Число выведено из расчётного потолка записи в шесть часов с
|
||||
// запасом на видео; оно же стоит строкой в docs/database.md.
|
||||
&core.FileField{Name: "file", MaxSelect: 1, MaxSize: entity.MaxRecordSize},
|
||||
// Где лежит копия. Поле названо `location`, а не `storage`: последним
|
||||
// словом зовут само хранилище, и третий смысл развёл бы одно слово по
|
||||
// разным вещам.
|
||||
&core.SelectField{
|
||||
Name: "location",
|
||||
Values: []string{entity.LocationLocal, entity.LocationS3},
|
||||
MaxSelect: 1,
|
||||
Required: true,
|
||||
},
|
||||
// Ключ объекта во внешнем хранилище; у местной копии пуст.
|
||||
&core.TextField{Name: "object_key"},
|
||||
&core.NumberField{Name: "size", OnlyInt: true},
|
||||
&core.AutodateField{Name: "created", OnCreate: true},
|
||||
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
|
||||
)
|
||||
|
||||
if err := app.Save(files); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
jobs := core.NewBaseCollection(JobsCollection)
|
||||
jobs.Fields.Add(
|
||||
// Перечень состояний закрыт схемой: задача, заведённая в панели руками,
|
||||
// не должна попасть в выборку с состоянием, которого конвейер не знает.
|
||||
&core.SelectField{
|
||||
Name: "state",
|
||||
Values: []string{
|
||||
entity.StateCreated,
|
||||
entity.StateConverted,
|
||||
entity.StateTranscribe,
|
||||
entity.StateDone,
|
||||
entity.StateFailed,
|
||||
entity.StateDead,
|
||||
},
|
||||
MaxSelect: 1,
|
||||
Required: true,
|
||||
},
|
||||
&core.SelectField{
|
||||
Name: "source",
|
||||
Values: []string{entity.SourceUnknown, entity.SourceApi, entity.SourceTelegram},
|
||||
MaxSelect: 1,
|
||||
Required: true,
|
||||
},
|
||||
// Текущий файл задачи: шаг конвейера переставляет ссылку на свой
|
||||
// результат.
|
||||
// Обязательна: задача без записи не может пройти ни одного шага, и
|
||||
// заведённая в панели руками она дошла бы до шага только затем, чтобы
|
||||
// отказать. Компилятор этого не держит — держит схема.
|
||||
&core.RelationField{
|
||||
Name: "file",
|
||||
CollectionId: files.Id,
|
||||
MaxSelect: 1,
|
||||
Required: true,
|
||||
},
|
||||
&core.TextField{Name: "error_text"},
|
||||
&core.TextField{Name: "acquisition_id"},
|
||||
&core.DateField{Name: "acquire_time"},
|
||||
&core.DateField{Name: "delay_time"},
|
||||
// Число попыток: растёт при каждом захвате, обнуляется на шаге,
|
||||
// завершившемся без отказа.
|
||||
&core.NumberField{Name: "attempts", OnlyInt: true, Min: ptr(0.0)},
|
||||
&core.TextField{Name: "recognition_op_id"},
|
||||
&core.EditorField{Name: "transcription_text"},
|
||||
&core.NumberField{Name: "tg_chat_id", OnlyInt: true},
|
||||
&core.NumberField{Name: "tg_reply_message_id", OnlyInt: true},
|
||||
&core.AutodateField{Name: "created", OnCreate: true},
|
||||
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
|
||||
)
|
||||
|
||||
// Выборка воркера идёт по состоянию, паузе и сроку захвата — индекс по
|
||||
// состоянию снимает полный перебор, который был у прежней таблицы.
|
||||
jobs.AddIndex("idx_transcribe_jobs_state", false, "state", "")
|
||||
|
||||
return app.Save(jobs)
|
||||
}
|
||||
|
||||
func down202608110001(app core.App) error {
|
||||
// Порядок обратный порядку заведения: задачи ссылаются на файлы.
|
||||
for _, name := range []string{JobsCollection, FilesCollection} {
|
||||
collection, err := app.FindCollectionByNameOrId(name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := app.Delete(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
@@ -0,0 +1,36 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// BindPanelRules подчиняет правку задачи в панели тем же правилам перехода, что
|
||||
// и правку из кода.
|
||||
//
|
||||
// Панель — вход в задачу наравне с конвейером, а не окно просмотра: ради правки
|
||||
// она и покупалась, мёртвая задача оживляется сменой состояния. Но правка полем
|
||||
// идёт мимо кода, который чистит служебные поля прошлого состояния, и владелец,
|
||||
// «вернувший задачу в работу», получил бы задачу с прежним признаком захвата
|
||||
// (захвату она не выдастся до конца срока) и с числом попыток на пределе (умрёт
|
||||
// от первого же отказа). Узнать об этом ему неоткуда.
|
||||
//
|
||||
// Хук стоит на правке **запросом**, а не на всяком сохранении записи. Модельное
|
||||
// событие не различает, кто пишет, и срабатывало бы на каждом переходе
|
||||
// конвейера: тогда задержка, поставленная шагом вместе со сменой состояния,
|
||||
// стиралась бы тем же сохранением, а число попыток мёртвой задачи — которое
|
||||
// переход хранит намеренно — приходило бы владельцу нулём.
|
||||
func BindPanelRules(app core.App) {
|
||||
app.OnRecordUpdateRequest(JobsCollection).BindFunc(func(e *core.RecordRequestEvent) error {
|
||||
original := e.Record.Original()
|
||||
if original == nil || original.GetString("state") == e.Record.GetString("state") {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
e.Record.Set("acquisition_id", "")
|
||||
e.Record.Set("acquire_time", "")
|
||||
e.Record.Set("delay_time", "")
|
||||
e.Record.Set("attempts", 0)
|
||||
|
||||
return e.Next()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
type TranscriptJobRepository struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
func NewTranscriptJobRepository(app core.App) *TranscriptJobRepository {
|
||||
return &TranscriptJobRepository{app: app}
|
||||
}
|
||||
|
||||
func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
|
||||
collection, err := findCollection(repo.app, JobsCollection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
if job.Id != "" {
|
||||
record.Id = job.Id
|
||||
}
|
||||
applyToRecord(record, job)
|
||||
|
||||
if err := repo.app.Save(record); err != nil {
|
||||
return fmt.Errorf("failed to insert transcribe job: %w", err)
|
||||
}
|
||||
|
||||
job.Id = record.Id
|
||||
job.CreatedAt = record.GetDateTime("created").Time()
|
||||
job.UpdatedAt = record.GetDateTime("updated").Time()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save сохраняет задачу, захват которой держит holder. Проверка и запись идут
|
||||
// одной транзакцией: шаг, потерявший задачу за время работы, получает
|
||||
// LostAcquisitionError и результата не пишет.
|
||||
func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob, holder string) error {
|
||||
err := repo.app.RunInTransaction(func(txApp core.App) error {
|
||||
record, err := txApp.FindRecordById(JobsCollection, job.Id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find transcribe job: %w", err)
|
||||
}
|
||||
|
||||
if holder != "" && record.GetString("acquisition_id") != holder {
|
||||
return &contract.LostAcquisitionError{JobID: job.Id}
|
||||
}
|
||||
|
||||
// Кладём только то, чем распоряжается конвейер: правку владельца в
|
||||
// панели снимок шага стирать не должен.
|
||||
applyOwnedByPipeline(record, job)
|
||||
|
||||
if err := txApp.Save(record); err != nil {
|
||||
return fmt.Errorf("failed to update transcribe job: %w", err)
|
||||
}
|
||||
|
||||
job.UpdatedAt = record.GetDateTime("updated").Time()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo *TranscriptJobRepository) GetByID(id string) (*entity.TranscribeJob, error) {
|
||||
record, err := repo.app.FindRecordById(JobsCollection, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get transcribe job: %w", err)
|
||||
}
|
||||
return recordToJob(record), nil
|
||||
}
|
||||
|
||||
// Колонки, которые читает захват. Список нужен запросу дословно: `RETURNING *`
|
||||
// отдал бы и порядок, зависящий от схемы.
|
||||
const acquireColumns = `id, state, source, file, error_text, acquisition_id, ` +
|
||||
`acquire_time, delay_time, attempts, recognition_op_id, transcription_text, ` +
|
||||
`tg_chat_id, tg_reply_message_id, created, updated`
|
||||
|
||||
// FindAndAcquire забирает задачу одним неделимым шагом: выбор подходящей и
|
||||
// пометка её захваченной идут вместе, и захваченная возвращается тем же
|
||||
// запросом. Двум вызывающим, пришедшим за одним состоянием, запись достаётся
|
||||
// одному — на этом стоит инвариант «Принятая запись не теряется молча».
|
||||
//
|
||||
// Запрос идёт сырым, мимо записей коллекции: `app.DB()` направляет всё, кроме
|
||||
// выборок, в пул с единственным соединением, и захваты выстраиваются в очередь.
|
||||
// Хуки коллекции на нём не срабатывают, поэтому время изменения проставляет сам
|
||||
// запрос.
|
||||
//
|
||||
// Все времена кладутся и сравниваются тем же видом, каким хранилище пишет свои
|
||||
// `created`/`updated`: сравнение строк побайтово, и вид, разошедшийся хоть
|
||||
// разделителем, обратил бы условие срока в постоянную истину или постоянную
|
||||
// ложь — молча.
|
||||
func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error) {
|
||||
now := types.NowDateTime()
|
||||
|
||||
query := repo.app.DB().NewQuery(`
|
||||
UPDATE {{` + JobsCollection + `}}
|
||||
SET acquisition_id = {:acquisition_id},
|
||||
acquire_time = {:now},
|
||||
attempts = attempts + 1,
|
||||
updated = {:now}
|
||||
WHERE id = (
|
||||
SELECT id FROM {{` + JobsCollection + `}}
|
||||
WHERE state = {:state}
|
||||
AND (delay_time = '' OR delay_time IS NULL OR delay_time < {:now})
|
||||
AND (acquisition_id = '' OR acquisition_id IS NULL OR acquire_time < {:rotting})
|
||||
ORDER BY created, id
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING ` + acquireColumns)
|
||||
|
||||
rotting, err := types.ParseDateTime(rottingTime)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse rotting time: %w", err)
|
||||
}
|
||||
|
||||
query.Bind(dbx.Params{
|
||||
"acquisition_id": acquisitionId,
|
||||
"now": now.String(),
|
||||
"state": state,
|
||||
"rotting": rotting.String(),
|
||||
})
|
||||
|
||||
var row acquiredRow
|
||||
if err := query.One(&row); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, &contract.JobNotFoundError{State: state, Message: "appropriate job not found"}
|
||||
}
|
||||
return nil, fmt.Errorf("failed to aquire job with state %s: %w", state, err)
|
||||
}
|
||||
|
||||
return row.toJob(), nil
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// newTestApp поднимает хранилище на пустом каталоге и накатывает схему — тем же
|
||||
// путём, каким это делает сервис при старте.
|
||||
func newTestApp(t *testing.T) core.App {
|
||||
t.Helper()
|
||||
|
||||
app, err := New(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
if err := app.ResetBootstrapState(); err != nil {
|
||||
t.Logf("не удалось закрыть хранилище: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// newFile заводит запись о файле: ссылка на неё у задачи обязательна схемой.
|
||||
func newFile(t *testing.T, app core.App) *entity.File {
|
||||
t.Helper()
|
||||
|
||||
repo := NewFileRepository(app)
|
||||
work, err := repo.Stage(".mp3", strings.NewReader("запись"))
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, work.Close()) }()
|
||||
|
||||
file, err := repo.CreateLocal("sample.mp3", work)
|
||||
require.NoError(t, err)
|
||||
return file
|
||||
}
|
||||
|
||||
func newJob(t *testing.T, repo *TranscriptJobRepository, state string) *entity.TranscribeJob {
|
||||
t.Helper()
|
||||
|
||||
file := newFile(t, repo.app)
|
||||
job := &entity.TranscribeJob{State: state, Source: entity.SourceApi, FileID: &file.Id}
|
||||
require.NoError(t, repo.Create(job))
|
||||
return job
|
||||
}
|
||||
|
||||
// Захват неделим: выбор подходящей задачи и пометка её захваченной идут вместе.
|
||||
// Двум вызывающим, пришедшим за одним состоянием разом, запись достаётся
|
||||
// одному — на этом стоит инвариант «Принятая запись не теряется молча».
|
||||
func TestFindAndAcquire_OnlyOneOfThreeGetsTheJob(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
|
||||
job := newJob(t, repo, entity.StateCreated)
|
||||
|
||||
const racers = 3
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
got []*entity.TranscribeJob
|
||||
notFound int
|
||||
)
|
||||
|
||||
start := make(chan struct{})
|
||||
for i := 0; i < racers; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
|
||||
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
var missing *contract.JobNotFoundError
|
||||
if assert.ErrorAs(t, err, &missing) {
|
||||
notFound++
|
||||
}
|
||||
return
|
||||
}
|
||||
got = append(got, acquired)
|
||||
}(i)
|
||||
}
|
||||
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
require.Len(t, got, 1, "запись получает ровно один из трёх захватов")
|
||||
assert.Equal(t, job.Id, got[0].Id)
|
||||
assert.Equal(t, racers-1, notFound, "остальные получают признак «работы нет»")
|
||||
}
|
||||
|
||||
// Захваченная задача второй раз не выдаётся, пока срок захвата не истёк.
|
||||
func TestFindAndAcquire_AcquiredJobIsNotHandedOutAgain(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
|
||||
newJob(t, repo, entity.StateCreated)
|
||||
|
||||
first, err := repo.FindAndAcquire(entity.StateCreated, "first", time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, first)
|
||||
|
||||
_, err = repo.FindAndAcquire(entity.StateCreated, "second", time.Now().Add(-time.Hour))
|
||||
|
||||
var missing *contract.JobNotFoundError
|
||||
assert.ErrorAs(t, err, &missing, "захваченная задача второму не выдаётся")
|
||||
}
|
||||
|
||||
// Захват протухает, и задача достаётся снова. Время захвата кладётся **не**
|
||||
// нашим кодом, а тем же путём, что и `created`: проверка, кладущая его своим
|
||||
// форматом, была бы зелена и тогда, когда сравнение вида сломано.
|
||||
func TestFindAndAcquire_RottenAcquisitionIsHandedOutAgain(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
|
||||
job := newJob(t, repo, entity.StateCreated)
|
||||
|
||||
_, err := repo.FindAndAcquire(entity.StateCreated, "first", time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Задним числом — записью коллекции, то есть тем же слоем, который пишет
|
||||
// собственные времена хранилища.
|
||||
record, err := app.FindRecordById(JobsCollection, job.Id)
|
||||
require.NoError(t, err)
|
||||
record.Set("acquire_time", types.NowDateTime().Add(-2*time.Hour))
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
again, err := repo.FindAndAcquire(entity.StateCreated, "second", time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err, "протухший захват не мешает выдать задачу следующему")
|
||||
assert.Equal(t, job.Id, again.Id)
|
||||
}
|
||||
|
||||
// Пауза держит задачу от выдачи, пока не кончится.
|
||||
func TestFindAndAcquire_DelayedJobIsNotHandedOut(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
|
||||
job := newJob(t, repo, entity.StateCreated)
|
||||
|
||||
delay := time.Now().Add(time.Hour)
|
||||
job.DelayTime = &delay
|
||||
require.NoError(t, repo.Save(job, ""))
|
||||
|
||||
_, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
|
||||
|
||||
var missing *contract.JobNotFoundError
|
||||
assert.ErrorAs(t, err, &missing, "задача не выдаётся, пока пауза не кончилась")
|
||||
}
|
||||
|
||||
// Число попыток растёт при каждом захвате: только так попытка засчитывается и
|
||||
// задаче, брошенной вместе с процессом.
|
||||
func TestFindAndAcquire_AttemptsGrowOnEveryAcquisition(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
|
||||
newJob(t, repo, entity.StateCreated)
|
||||
|
||||
for expected := 1; expected <= 3; expected++ {
|
||||
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expected, acquired.Attempts)
|
||||
}
|
||||
}
|
||||
|
||||
// Захват отдаёт задачу целиком, а не только её ключ: сырой запрос идёт мимо
|
||||
// записей коллекции, и расхождение перечня колонок иначе проявилось бы как
|
||||
// потерянное поле.
|
||||
func TestFindAndAcquire_ReturnsWholeJob(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
|
||||
chatId := int64(4242)
|
||||
replyId := 17
|
||||
opId := "operation-id"
|
||||
text := "расшифровка"
|
||||
|
||||
file := newFile(t, app)
|
||||
|
||||
job := &entity.TranscribeJob{
|
||||
State: entity.StateTranscribe,
|
||||
Source: entity.SourceTelegram,
|
||||
FileID: &file.Id,
|
||||
TgChatId: &chatId,
|
||||
TgReplyMessageId: &replyId,
|
||||
RecognitionOpID: &opId,
|
||||
TranscriptionText: &text,
|
||||
}
|
||||
require.NoError(t, repo.Create(job))
|
||||
|
||||
acquired, err := repo.FindAndAcquire(entity.StateTranscribe, "holder", time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, job.Id, acquired.Id)
|
||||
assert.Equal(t, entity.StateTranscribe, acquired.State)
|
||||
assert.Equal(t, entity.SourceTelegram, acquired.Source)
|
||||
require.NotNil(t, acquired.TgChatId)
|
||||
assert.Equal(t, chatId, *acquired.TgChatId)
|
||||
require.NotNil(t, acquired.TgReplyMessageId)
|
||||
assert.Equal(t, replyId, *acquired.TgReplyMessageId)
|
||||
require.NotNil(t, acquired.RecognitionOpID)
|
||||
assert.Equal(t, opId, *acquired.RecognitionOpID)
|
||||
require.NotNil(t, acquired.TranscriptionText)
|
||||
assert.Equal(t, text, *acquired.TranscriptionText)
|
||||
assert.False(t, acquired.CreatedAt.IsZero(), "время заведения доехало")
|
||||
}
|
||||
|
||||
// Шаг, потерявший захват за время работы, результата не пишет: иначе два
|
||||
// воркера пишут в одну задачу по очереди, а отправитель получает два ответа.
|
||||
func TestSave_RefusesWriteFromLostAcquisition(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
|
||||
newJob(t, repo, entity.StateCreated)
|
||||
|
||||
mine, err := repo.FindAndAcquire(entity.StateCreated, "mine", time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Задача досталась другому, пока шаг работал.
|
||||
record, err := app.FindRecordById(JobsCollection, mine.Id)
|
||||
require.NoError(t, err)
|
||||
record.Set("acquisition_id", "someone-else")
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
mine.MoveToState(entity.StateConverted)
|
||||
err = repo.Save(mine, "mine")
|
||||
|
||||
var lost *contract.LostAcquisitionError
|
||||
require.ErrorAs(t, err, &lost)
|
||||
|
||||
// И состояние не поехало.
|
||||
after, err := repo.GetByID(mine.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateCreated, after.State)
|
||||
}
|
||||
|
||||
// Пустой держатель значит «задача не захватывалась» — так её сохраняет приём.
|
||||
func TestSave_WithoutHolderWritesAnyway(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
|
||||
job := newJob(t, repo, entity.StateCreated)
|
||||
job.MoveToState(entity.StateConverted)
|
||||
|
||||
require.NoError(t, repo.Save(job, ""))
|
||||
|
||||
after, err := repo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateConverted, after.State)
|
||||
}
|
||||
|
||||
// Правка состояния **запросом** — то есть из панели — чистит служебные поля
|
||||
// прошлого состояния: те же, что чистит переход из кода. Иначе владелец,
|
||||
// вернувший мёртвую задачу в работу, получил бы задачу, которая не выдаётся
|
||||
// захвату и умирает от первого же отказа, и не узнал бы об этом.
|
||||
func TestPanelRules_StateChangeByRequestClearsAcquisition(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
BindPanelRules(app)
|
||||
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
job := newJob(t, repo, entity.StateCreated)
|
||||
|
||||
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, acquired.AcquisitionID)
|
||||
|
||||
record, err := app.FindRecordById(JobsCollection, job.Id)
|
||||
require.NoError(t, err)
|
||||
record.Set("attempts", 5)
|
||||
record.Set("state", entity.StateDead)
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
// Владелец возвращает задачу в работу правкой состояния в панели — то есть
|
||||
// запросом к записи, а не сохранением из кода.
|
||||
patchRecord(t, app, job.Id, `{"state":"`+entity.StateCreated+`"}`)
|
||||
|
||||
after, err := repo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, after.AcquisitionID, "признак захвата снят")
|
||||
assert.Nil(t, after.AcquireTime, "время захвата снято")
|
||||
assert.Nil(t, after.DelayTime, "пауза снята")
|
||||
assert.Equal(t, 0, after.Attempts, "число попыток обнулено")
|
||||
|
||||
// И ближайший захват задачу выдаёт.
|
||||
again, err := repo.FindAndAcquire(entity.StateCreated, "next", time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, job.Id, again.Id)
|
||||
}
|
||||
|
||||
// Обратная сторона того же правила, и она дороже: правила панели MUST не
|
||||
// трогать записи, которые правит сам конвейер. Модельный хук их не различал, и
|
||||
// пауза, поставленная шагом вместе со сменой состояния, стиралась тем же
|
||||
// сохранением, а число попыток мёртвой задачи приходило владельцу нулём.
|
||||
func TestPanelRules_DoNotTouchPipelineWrites(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
BindPanelRules(app)
|
||||
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
job := newJob(t, repo, entity.StateConverted)
|
||||
|
||||
acquired, err := repo.FindAndAcquire(entity.StateConverted, "holder", time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Шаг ставит задержку опроса вместе со сменой состояния.
|
||||
delay := time.Now().Add(10 * time.Second)
|
||||
acquired.MoveToStateAndDelay(entity.StateTranscribe, &delay)
|
||||
require.NoError(t, repo.Save(acquired, "holder"))
|
||||
|
||||
after, err := repo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, after.DelayTime, "задержка, поставленная шагом, пережила сохранение")
|
||||
|
||||
// Переход в «мертва» хранит число попыток намеренно: по нему владелец видит,
|
||||
// сколько раз мы пробовали.
|
||||
after.Attempts = 6
|
||||
after.Die("attempts exhausted: 6")
|
||||
require.NoError(t, repo.Save(after, ""))
|
||||
|
||||
dead, err := repo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateDead, dead.State)
|
||||
assert.Equal(t, 6, dead.Attempts, "число попыток мёртвой задачи сохранено")
|
||||
}
|
||||
|
||||
// patchRecord правит запись тем же путём, каким её правит панель: запросом к
|
||||
// API от имени владельца.
|
||||
func patchRecord(t *testing.T, app core.App, recordID, body string) {
|
||||
t.Helper()
|
||||
|
||||
superusers, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
|
||||
require.NoError(t, err)
|
||||
|
||||
owner := core.NewRecord(superusers)
|
||||
owner.Set("email", "owner@example.com")
|
||||
owner.Set("password", "ownerpassword123")
|
||||
require.NoError(t, app.Save(owner))
|
||||
|
||||
token, err := owner.NewStaticAuthToken(time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
router, err := apis.NewRouter(app)
|
||||
require.NoError(t, err)
|
||||
mux, err := router.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/collections/"+JobsCollection+"/records/"+recordID,
|
||||
strings.NewReader(body),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", token)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code, "правка записи владельцем: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// Правка владельца в панели переживает сохранение шага. Шаг держит задачу
|
||||
// снимком с момента захвата и до своего сохранения — до восьми часов, — и
|
||||
// безусловная запись снимка стёрла бы правку молча: ни строки в журнале, ни
|
||||
// отказа в панели.
|
||||
func TestSave_KeepsOwnerEditMadeWhileStepHeldTheJob(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
BindPanelRules(app)
|
||||
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
|
||||
file := newFile(t, app)
|
||||
chatId := int64(111)
|
||||
job := &entity.TranscribeJob{
|
||||
State: entity.StateCreated,
|
||||
Source: entity.SourceTelegram,
|
||||
FileID: &file.Id,
|
||||
TgChatId: &chatId,
|
||||
}
|
||||
require.NoError(t, repo.Create(job))
|
||||
|
||||
// Шаг захватил задачу и работает.
|
||||
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Владелец правит в панели поле, которого конвейер не касается.
|
||||
patchRecord(t, app, job.Id, `{"tg_chat_id":999999}`)
|
||||
|
||||
// Шаг доработал и сохраняет свой снимок.
|
||||
acquired.MoveToState(entity.StateConverted)
|
||||
require.NoError(t, repo.Save(acquired, "holder"))
|
||||
|
||||
after, err := repo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateConverted, after.State, "шаг свой результат записал")
|
||||
require.NotNil(t, after.TgChatId)
|
||||
assert.Equal(t, int64(999999), *after.TgChatId, "правка владельца пережила сохранение шага")
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
)
|
||||
|
||||
type FileRepository struct {
|
||||
db *sql.DB
|
||||
gq *goqu.Database
|
||||
}
|
||||
|
||||
func NewFileRepository(conn *sql.DB, gq *goqu.Database) *FileRepository {
|
||||
return &FileRepository{conn, gq}
|
||||
}
|
||||
|
||||
func (repo *FileRepository) Create(file *entity.File) error {
|
||||
record := goqu.Record{
|
||||
"id": file.Id,
|
||||
"storage": file.Storage,
|
||||
"file_name": file.FileName,
|
||||
"size": file.Size,
|
||||
"created_at": file.CreatedAt,
|
||||
}
|
||||
query := repo.gq.Insert("files").Rows(record)
|
||||
sql, args, err := query.ToSQL()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build query: %w", err)
|
||||
}
|
||||
|
||||
_, err = repo.db.Exec(sql, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
|
||||
query := repo.gq.From("files").Select("id", "storage", "file_name", "size", "created_at").Where(goqu.C("id").Eq(id))
|
||||
sql, args, err := query.ToSQL()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build query: %w", err)
|
||||
}
|
||||
|
||||
var file entity.File
|
||||
err = repo.db.QueryRow(sql, args...).Scan(&file.Id, &file.Storage, &file.FileName, &file.Size, &file.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get file: %w", err)
|
||||
}
|
||||
|
||||
return &file, nil
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
goqu "github.com/doug-martin/goqu/v9"
|
||||
)
|
||||
|
||||
type TranscriptJobRepository struct {
|
||||
db *sql.DB
|
||||
gq *goqu.Database
|
||||
}
|
||||
|
||||
func NewTranscriptJobRepository(db *sql.DB, gq *goqu.Database) *TranscriptJobRepository {
|
||||
return &TranscriptJobRepository{db, gq}
|
||||
}
|
||||
|
||||
func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
|
||||
record := goqu.Record{
|
||||
"id": job.Id,
|
||||
"state": job.State,
|
||||
"source": job.Source,
|
||||
"file_id": job.FileID,
|
||||
"is_error": job.IsError,
|
||||
"error_text": job.ErrorText,
|
||||
"acquisition_id": job.AcquisitionID,
|
||||
"acquire_time": job.AcquireTime,
|
||||
"delay_time": job.DelayTime,
|
||||
"recognition_op_id": job.RecognitionOpID,
|
||||
"transcription_text": job.TranscriptionText,
|
||||
"tg_chat_id": job.TgChatId,
|
||||
"tg_reply_message_id": job.TgReplyMessageId,
|
||||
"created_at": job.CreatedAt,
|
||||
"updated_at": job.UpdatedAt,
|
||||
}
|
||||
query := repo.gq.Insert("transcribe_jobs").Rows(record)
|
||||
sql, args, err := query.ToSQL()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build query: %w", err)
|
||||
}
|
||||
|
||||
_, err = repo.db.Exec(sql, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert transcribe job: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob) error {
|
||||
record := goqu.Record{
|
||||
"state": job.State,
|
||||
"source": job.Source,
|
||||
"file_id": job.FileID,
|
||||
"is_error": job.IsError,
|
||||
"error_text": job.ErrorText,
|
||||
"acquisition_id": job.AcquisitionID,
|
||||
"acquire_time": job.AcquireTime,
|
||||
"delay_time": job.DelayTime,
|
||||
"recognition_op_id": job.RecognitionOpID,
|
||||
"transcription_text": job.TranscriptionText,
|
||||
"tg_chat_id": job.TgChatId,
|
||||
"tg_reply_message_id": job.TgReplyMessageId,
|
||||
"updated_at": job.UpdatedAt,
|
||||
}
|
||||
query := repo.gq.Update("transcribe_jobs").Set(record).Where(goqu.C("id").Eq(job.Id))
|
||||
sql, args, err := query.ToSQL()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build query: %w", err)
|
||||
}
|
||||
|
||||
_, err = repo.db.Exec(sql, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update transcribe job: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo *TranscriptJobRepository) GetByID(id string) (*entity.TranscribeJob, error) {
|
||||
query := repo.gq.From("transcribe_jobs").Select(
|
||||
"id",
|
||||
"state",
|
||||
"source",
|
||||
"file_id",
|
||||
"is_error",
|
||||
"error_text",
|
||||
"acquisition_id",
|
||||
"acquire_time",
|
||||
"delay_time",
|
||||
"recognition_op_id",
|
||||
"transcription_text",
|
||||
"tg_chat_id",
|
||||
"tg_reply_message_id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
).Where(goqu.C("id").Eq(id))
|
||||
sql, args, err := query.ToSQL()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build query: %w", err)
|
||||
}
|
||||
|
||||
var job entity.TranscribeJob
|
||||
err = repo.db.QueryRow(sql, args...).Scan(
|
||||
&job.Id,
|
||||
&job.State,
|
||||
&job.Source,
|
||||
&job.FileID,
|
||||
&job.IsError,
|
||||
&job.ErrorText,
|
||||
&job.AcquisitionID,
|
||||
&job.AcquireTime,
|
||||
&job.DelayTime,
|
||||
&job.RecognitionOpID,
|
||||
&job.TranscriptionText,
|
||||
&job.TgChatId,
|
||||
&job.TgReplyMessageId,
|
||||
&job.CreatedAt,
|
||||
&job.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get transcribe job: %w", err)
|
||||
}
|
||||
|
||||
return &job, nil
|
||||
}
|
||||
|
||||
func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error) {
|
||||
updateQuery := repo.gq.Update("transcribe_jobs").
|
||||
Set(
|
||||
goqu.Record{
|
||||
"acquisition_id": acquisitionId,
|
||||
"acquire_time": time.Now(),
|
||||
},
|
||||
).
|
||||
Where(
|
||||
goqu.C("id").Eq(
|
||||
repo.gq.From("transcribe_jobs").Select("id").
|
||||
Where(
|
||||
goqu.And(
|
||||
goqu.C("state").Eq(state),
|
||||
goqu.C("is_error").Eq(0),
|
||||
goqu.Or(
|
||||
goqu.C("delay_time").IsNull(),
|
||||
goqu.C("delay_time").Lt(time.Now()),
|
||||
),
|
||||
goqu.Or(
|
||||
goqu.C("acquisition_id").IsNull(),
|
||||
goqu.C("acquire_time").Lt(rottingTime),
|
||||
),
|
||||
),
|
||||
).
|
||||
Limit(1),
|
||||
),
|
||||
)
|
||||
|
||||
sql, args, err := updateQuery.ToSQL()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build query: %w", err)
|
||||
}
|
||||
|
||||
// log.Printf("aquire sql: %s", sql)
|
||||
|
||||
result, err := repo.db.Exec(sql, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to aquire job with state %s: %w", state, err)
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed check affected rows: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
e := contract.JobNotFoundError{State: state, Message: "appropriate job not found"}
|
||||
return nil, &e
|
||||
}
|
||||
if rowsAffected != 1 {
|
||||
return nil, fmt.Errorf("unexpected affected rows count: %d", rowsAffected)
|
||||
}
|
||||
|
||||
selectQuery := repo.gq.From("transcribe_jobs").Select(
|
||||
"id",
|
||||
"state",
|
||||
"source",
|
||||
"file_id",
|
||||
"is_error",
|
||||
"error_text",
|
||||
"acquisition_id",
|
||||
"acquire_time",
|
||||
"delay_time",
|
||||
"recognition_op_id",
|
||||
"transcription_text",
|
||||
"tg_chat_id",
|
||||
"tg_reply_message_id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
).Where(goqu.C("acquisition_id").Eq(acquisitionId))
|
||||
|
||||
sql, args, err = selectQuery.ToSQL()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build query: %w", err)
|
||||
}
|
||||
|
||||
var job entity.TranscribeJob
|
||||
err = repo.db.QueryRow(sql, args...).Scan(
|
||||
&job.Id,
|
||||
&job.State,
|
||||
&job.Source,
|
||||
&job.FileID,
|
||||
&job.IsError,
|
||||
&job.ErrorText,
|
||||
&job.AcquisitionID,
|
||||
&job.AcquireTime,
|
||||
&job.DelayTime,
|
||||
&job.RecognitionOpID,
|
||||
&job.TranscriptionText,
|
||||
&job.TgChatId,
|
||||
&job.TgReplyMessageId,
|
||||
&job.CreatedAt,
|
||||
&job.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get transcribe job: %w", err)
|
||||
}
|
||||
|
||||
return &job, nil
|
||||
}
|
||||
Reference in New Issue
Block a user