внутренняя модель перестроена вокруг аудиозаписи
- audiorecords вместо transcribe_jobs: приложения (texts, structures, recognitions, record_events, topics) живут своими коллекциями, ссылки на исходник и на приведённую копию перестали переставляться - рубеж называет достигнутое, отказ стал признаком остановки с причиной, а сторожей стало двое: число отказов и время в рубеже - воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
This commit is contained in:
@@ -112,11 +112,15 @@ func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
|
||||
return work, nil
|
||||
}
|
||||
|
||||
// CreateLocal кладёт рабочую копию в хранилище. Имя задаём мы: умолчание
|
||||
// библиотеки строит его из имени, данного отправителем, а имя отправителя в
|
||||
// хранилище не попадает — путь к файлу читается в журнале, и инвариант
|
||||
// приватности этого не допускает. Свой суффикс хранилище допишет само.
|
||||
func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile, ownerID string) (*entity.File, error) {
|
||||
// Create кладёт рабочую копию в хранилище. Имя задаём мы: умолчание библиотеки
|
||||
// строит его из имени, данного отправителем, а имя отправителя в хранилище не
|
||||
// попадает — путь к файлу читается в журнале, и инвариант приватности этого не
|
||||
// допускает. Свой суффикс хранилище допишет само.
|
||||
//
|
||||
// Копий у записи ровно две — принятая и приведённая, — и обе местные. Прежний
|
||||
// путь заведения записи о копии во внешнем хранилище отсюда ушёл: та копия
|
||||
// файлом записи не считается, а её ключ живёт в строке попытки распознавания.
|
||||
func (repo *FileRepository) Create(name string, work contract.WorkFile, meta contract.FileMeta, ownerID string) (*entity.File, error) {
|
||||
collection, err := findCollection(repo.app, migrations.FilesCollection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -132,6 +136,8 @@ func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile, own
|
||||
record.Set("file", stored)
|
||||
record.Set("location", entity.LocationLocal)
|
||||
record.Set("size", stored.Size)
|
||||
record.Set("format", meta.Format)
|
||||
record.Set("duration_ms", meta.DurationMs)
|
||||
// Владелец файла — владелец записи, которой файл принадлежит. Пустой значит
|
||||
// «файл без владельца»: таков всякий файл записи, принятой ботом. Правило
|
||||
// просмотра коллекции сужено этой колонкой, и без неё чужое аудио осталось
|
||||
@@ -148,25 +154,6 @@ func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile, own
|
||||
return recordToFile(record), nil
|
||||
}
|
||||
|
||||
func (repo *FileRepository) CreateRemote(objectKey string, size int64, ownerID string) (*entity.File, error) {
|
||||
collection, err := findCollection(repo.app, migrations.FilesCollection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
record.Set("location", entity.LocationS3)
|
||||
record.Set("object_key", objectKey)
|
||||
record.Set("size", size)
|
||||
record.Set("owner", ownerID)
|
||||
|
||||
if err := repo.app.Save(record); err != nil {
|
||||
return nil, fmt.Errorf("failed to store remote file record: %w", err)
|
||||
}
|
||||
|
||||
return recordToFile(record), nil
|
||||
}
|
||||
|
||||
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
|
||||
record, err := repo.app.FindRecordById(migrations.FilesCollection, id)
|
||||
if err != nil {
|
||||
@@ -262,16 +249,13 @@ func firstFileName(record *core.Record) string {
|
||||
}
|
||||
|
||||
func recordToFile(record *core.Record) *entity.File {
|
||||
name := firstFileName(record)
|
||||
if name == "" {
|
||||
name = record.GetString("object_key")
|
||||
}
|
||||
|
||||
return &entity.File{
|
||||
Id: record.Id,
|
||||
Location: record.GetString("location"),
|
||||
FileName: name,
|
||||
Size: int64(record.GetInt("size")),
|
||||
CreatedAt: record.GetDateTime("created").Time(),
|
||||
Id: record.Id,
|
||||
Location: record.GetString("location"),
|
||||
FileName: firstFileName(record),
|
||||
Size: int64(record.GetInt("size")),
|
||||
Format: record.GetString("format"),
|
||||
DurationMs: int64(record.GetInt("duration_ms")),
|
||||
CreatedAt: record.GetDateTime("created").Time(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Потолок размера у поля файла задан числом, а не нулём: нулём библиотека читает
|
||||
// собственное умолчание в 5 МиБ, и на нём отвергалось бы всё длиннее примерно
|
||||
// пяти минут — то есть штатная запись сервиса. Проверка судит запись, которая
|
||||
// заведомо больше этого умолчания: обновление библиотеки, вернувшее умолчание,
|
||||
// иначе прошло бы молча.
|
||||
func TestCreateLocal_AcceptsRecordLargerThanLibraryDefault(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
repo := NewFileRepository(app)
|
||||
|
||||
const libraryDefault = 5 << 20
|
||||
|
||||
// Ровно на байт больше умолчания: проверка судит границу, а не пропускную
|
||||
// способность — лишние мегабайты стоили бы секунд на каждом прогоне.
|
||||
work, err := repo.Stage(".mp3", strings.NewReader(strings.Repeat("a", libraryDefault+1)))
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, work.Close()) }()
|
||||
|
||||
size, err := work.Size()
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, size, int64(libraryDefault), "запись заведомо больше умолчания библиотеки")
|
||||
|
||||
file, err := repo.CreateLocal("big.mp3", work, "")
|
||||
require.NoError(t, err, "запись длиннее умолчания библиотеки ложится в хранилище")
|
||||
assert.Equal(t, size, file.Size)
|
||||
assert.Greater(t, entity.MaxRecordSize, size, "объявленный потолок выше проверяемого размера")
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Отображение задачи в запись коллекции и обратно живёт одним местом. Прежде
|
||||
// список колонок был переписан четырежды — в каждом запросе своего слоя, — и
|
||||
// расхождение проявлялось как потерянное при сохранении поле.
|
||||
|
||||
// applyOwnedByPipeline кладёт в запись только те поля, которыми распоряжается
|
||||
// конвейер. Поля, которые он не меняет никогда — куда отвечать отправителю и
|
||||
// каким входом пришла запись, — не трогаются вовсе.
|
||||
//
|
||||
// Разрез нужен потому, что шаг держит задачу снимком с момента захвата и до
|
||||
// своего сохранения, а это до восьми часов. Всё, что владелец правил в панели за
|
||||
// это время, безусловная запись снимка стёрла бы молча: ни строки в журнале, ни
|
||||
// отказа в панели — владелец видел бы успешное сохранение и был бы уверен, что
|
||||
// правка на месте.
|
||||
func applyOwnedByPipeline(record *core.Record, job *entity.TranscribeJob) {
|
||||
record.Set("state", job.State)
|
||||
record.Set("file", derefString(job.FileID))
|
||||
record.Set("error_text", derefString(job.ErrorText))
|
||||
record.Set("acquisition_id", derefString(job.AcquisitionID))
|
||||
record.Set("acquire_time", dateOrEmpty(job.AcquireTime))
|
||||
record.Set("delay_time", dateOrEmpty(job.DelayTime))
|
||||
record.Set("attempts", job.Attempts)
|
||||
record.Set("recognition_op_id", derefString(job.RecognitionOpID))
|
||||
record.Set("transcription_text", derefString(job.TranscriptionText))
|
||||
}
|
||||
|
||||
// applyToRecord кладёт задачу в запись целиком — это заведение, и спорить за
|
||||
// поля здесь не с кем.
|
||||
func applyToRecord(record *core.Record, job *entity.TranscribeJob) {
|
||||
applyOwnedByPipeline(record, job)
|
||||
// Владелец кладётся только здесь, при заведении. В applyOwnedByPipeline его
|
||||
// нет намеренно: конвейер владельца не назначает и не меняет, а снимок шага,
|
||||
// записанный поверх, стёр бы его молча.
|
||||
record.Set("owner", derefString(job.OwnerID))
|
||||
record.Set("source", job.Source)
|
||||
record.Set("tg_chat_id", derefInt64(job.TgChatId))
|
||||
record.Set("tg_reply_message_id", derefInt(job.TgReplyMessageId))
|
||||
}
|
||||
|
||||
func recordToJob(record *core.Record) *entity.TranscribeJob {
|
||||
return &entity.TranscribeJob{
|
||||
Id: record.Id,
|
||||
State: record.GetString("state"),
|
||||
OwnerID: nilIfEmpty(record.GetString("owner")),
|
||||
Source: record.GetString("source"),
|
||||
FileID: nilIfEmpty(record.GetString("file")),
|
||||
ErrorText: nilIfEmpty(record.GetString("error_text")),
|
||||
AcquisitionID: nilIfEmpty(record.GetString("acquisition_id")),
|
||||
AcquireTime: timeOrNil(record.GetDateTime("acquire_time")),
|
||||
DelayTime: timeOrNil(record.GetDateTime("delay_time")),
|
||||
Attempts: record.GetInt("attempts"),
|
||||
RecognitionOpID: nilIfEmpty(record.GetString("recognition_op_id")),
|
||||
TranscriptionText: nilIfEmpty(record.GetString("transcription_text")),
|
||||
TgChatId: nilIfZero64(int64(record.GetInt("tg_chat_id"))),
|
||||
TgReplyMessageId: nilIfZeroInt(record.GetInt("tg_reply_message_id")),
|
||||
CreatedAt: record.GetDateTime("created").Time(),
|
||||
UpdatedAt: record.GetDateTime("updated").Time(),
|
||||
}
|
||||
}
|
||||
|
||||
// acquiredRow — задача, прочитанная сырым запросом захвата. Колонки читаются
|
||||
// именно так, потому что запрос идёт мимо записей коллекции; связь с их
|
||||
// перечнем держит константа acquireColumns и тест захвата, читающий задачу
|
||||
// целиком.
|
||||
type acquiredRow struct {
|
||||
Id string `db:"id"`
|
||||
State string `db:"state"`
|
||||
// Владелец конвейеру не нужен для выборки — она им не сужается, — но
|
||||
// читается: снимок задачи, в котором владелец всегда пуст, был бы ловушкой
|
||||
// для первого же шага, начавшего сохранять задачу целиком. Сегодня это ещё
|
||||
// и несущее чтение: шаг конвейера кладёт владельца на файл, который заводит.
|
||||
OwnerID sql.NullString `db:"owner"`
|
||||
Source string `db:"source"`
|
||||
FileID sql.NullString `db:"file"`
|
||||
ErrorText sql.NullString `db:"error_text"`
|
||||
AcquisitionID sql.NullString `db:"acquisition_id"`
|
||||
AcquireTime sql.NullString `db:"acquire_time"`
|
||||
DelayTime sql.NullString `db:"delay_time"`
|
||||
Attempts int `db:"attempts"`
|
||||
RecognitionOpID sql.NullString `db:"recognition_op_id"`
|
||||
TranscriptionText sql.NullString `db:"transcription_text"`
|
||||
TgChatId sql.NullInt64 `db:"tg_chat_id"`
|
||||
TgReplyMessageId sql.NullInt64 `db:"tg_reply_message_id"`
|
||||
Created sql.NullString `db:"created"`
|
||||
Updated sql.NullString `db:"updated"`
|
||||
}
|
||||
|
||||
func (r *acquiredRow) toJob() *entity.TranscribeJob {
|
||||
job := &entity.TranscribeJob{
|
||||
Id: r.Id,
|
||||
State: r.State,
|
||||
OwnerID: nullToPtr(r.OwnerID),
|
||||
Source: r.Source,
|
||||
FileID: nullToPtr(r.FileID),
|
||||
ErrorText: nullToPtr(r.ErrorText),
|
||||
AcquisitionID: nullToPtr(r.AcquisitionID),
|
||||
AcquireTime: parseTimeOrNil(r.AcquireTime),
|
||||
DelayTime: parseTimeOrNil(r.DelayTime),
|
||||
Attempts: r.Attempts,
|
||||
RecognitionOpID: nullToPtr(r.RecognitionOpID),
|
||||
TranscriptionText: nullToPtr(r.TranscriptionText),
|
||||
}
|
||||
|
||||
if r.TgChatId.Valid && r.TgChatId.Int64 != 0 {
|
||||
chatId := r.TgChatId.Int64
|
||||
job.TgChatId = &chatId
|
||||
}
|
||||
if r.TgReplyMessageId.Valid && r.TgReplyMessageId.Int64 != 0 {
|
||||
msgId := int(r.TgReplyMessageId.Int64)
|
||||
job.TgReplyMessageId = &msgId
|
||||
}
|
||||
if created := parseTimeOrNil(r.Created); created != nil {
|
||||
job.CreatedAt = *created
|
||||
}
|
||||
if updated := parseTimeOrNil(r.Updated); updated != nil {
|
||||
job.UpdatedAt = *updated
|
||||
}
|
||||
|
||||
return job
|
||||
}
|
||||
|
||||
func derefString(v *string) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
func derefInt64(v *int64) int64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
func derefInt(v *int) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// dateOrEmpty отдаёт пустое значение вместо нулевой даты: пустая колонка даты в
|
||||
// хранилище это пустая строка, и она же значит «времени нет».
|
||||
func dateOrEmpty(v *time.Time) any {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
date, err := types.ParseDateTime(*v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func nilIfEmpty(v string) *string {
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
func timeOrNil(v types.DateTime) *time.Time {
|
||||
if v.IsZero() {
|
||||
return nil
|
||||
}
|
||||
t := v.Time()
|
||||
return &t
|
||||
}
|
||||
|
||||
func nilIfZero64(v int64) *int64 {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
func nilIfZeroInt(v int) *int {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
func nullToPtr(v sql.NullString) *string {
|
||||
if !v.Valid || v.String == "" {
|
||||
return nil
|
||||
}
|
||||
s := v.String
|
||||
return &s
|
||||
}
|
||||
|
||||
func parseTimeOrNil(v sql.NullString) *time.Time {
|
||||
if !v.Valid || v.String == "" {
|
||||
return nil
|
||||
}
|
||||
date, err := types.ParseDateTime(v.String)
|
||||
if err != nil || date.IsZero() {
|
||||
return nil
|
||||
}
|
||||
t := date.Time()
|
||||
return &t
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// up202608140002 перестраивает модель вокруг аудиозаписи.
|
||||
//
|
||||
// Прежняя коллекция задач уходит целиком: сервис на сервере остановлен, а
|
||||
// прежние данные удалены решением владельца 2026-08-14 — переноса эта работа не
|
||||
// делает, и оставленная пустая коллекция висела бы в панели вторым домом для
|
||||
// того же понятия.
|
||||
//
|
||||
// Порядок заведения задан связями, а не вкусом: приложения ссылаются на запись,
|
||||
// а запись — на них, поэтому запись заводится первой без обратных ссылок, потом
|
||||
// приложения, и только потом ссылки дописываются.
|
||||
//
|
||||
// Правила доступа у новых коллекций остаются **незаданными**, то есть «только
|
||||
// владелец панели». Содержимое записи отдаёт собственный адрес сервиса, а не
|
||||
// поверхность хранилища; непустое правило открыло бы перечисление коллекции
|
||||
// впрок, а норма проекта велит держать эту поверхность закрытой.
|
||||
func up202608140002(app core.App) error {
|
||||
users, err := app.FindCollectionByNameOrId(UsersCollection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find users collection: %w", err)
|
||||
}
|
||||
|
||||
files, err := app.FindCollectionByNameOrId(FilesCollection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find files collection: %w", err)
|
||||
}
|
||||
|
||||
// Формат и длительность у копии: по ним видно, чем запись была, не открывая
|
||||
// её. Расширение наружу выходит только приведённым к перечню известных.
|
||||
files.Fields.Add(
|
||||
&core.TextField{Name: "format"},
|
||||
&core.NumberField{Name: "duration_ms", OnlyInt: true},
|
||||
)
|
||||
if err := app.Save(files); err != nil {
|
||||
return fmt.Errorf("failed to extend files: %w", err)
|
||||
}
|
||||
|
||||
topics, err := createTopics(app, users.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
records, err := createAudioRecords(app, users.Id, files.Id, topics.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
texts, err := createTexts(app, records.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
structures, err := createStructures(app, records.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
recognitions, err := createRecognitions(app, records.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := createRecordEvents(app, records.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Обратные ссылки дописываются последними: раньше коллекций-целей ещё нет.
|
||||
records.Fields.Add(
|
||||
&core.RelationField{Name: "transcript_text", CollectionId: texts.Id, MaxSelect: 1},
|
||||
&core.RelationField{Name: "literary_text", CollectionId: texts.Id, MaxSelect: 1},
|
||||
&core.RelationField{Name: "structure", CollectionId: structures.Id, MaxSelect: 1},
|
||||
&core.RelationField{Name: "recognition", CollectionId: recognitions.Id, MaxSelect: 1},
|
||||
)
|
||||
if err := app.Save(records); err != nil {
|
||||
return fmt.Errorf("failed to link audio records to their appendices: %w", err)
|
||||
}
|
||||
|
||||
jobs, err := app.FindCollectionByNameOrId(JobsCollection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find jobs collection: %w", err)
|
||||
}
|
||||
if err := app.Delete(jobs); err != nil {
|
||||
return fmt.Errorf("failed to drop the former jobs collection: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createAudioRecords заводит центральную сущность.
|
||||
//
|
||||
// Ссылки на файлы две и порознь: шаг конвейера больше не переставляет одну на
|
||||
// свой результат, и исходник остаётся доступным после того, как запись прошла
|
||||
// конвейер.
|
||||
func createAudioRecords(app core.App, usersID, filesID, topicsID string) (*core.Collection, error) {
|
||||
records := core.NewBaseCollection(RecordsCollection)
|
||||
records.Fields.Add(
|
||||
ownerField(usersID),
|
||||
&core.SelectField{
|
||||
Name: "source",
|
||||
Values: []string{entity.SourceUnknown, entity.SourceApi, entity.SourceTelegram},
|
||||
MaxSelect: 1,
|
||||
Required: true,
|
||||
},
|
||||
// Заголовок и краткое описание читаются вместе со списком, сотней штук
|
||||
// разом, и потому лежат колонками записи, а не строками текстов.
|
||||
&core.TextField{Name: "title"},
|
||||
&core.TextField{Name: "brief"},
|
||||
// Перечень рубежей закрыт схемой: запись, заведённая в панели руками, не
|
||||
// должна попасть в выборку с рубежом, которого конвейер не знает.
|
||||
&core.SelectField{
|
||||
Name: "state",
|
||||
Values: entity.AllStates(),
|
||||
MaxSelect: 1,
|
||||
Required: true,
|
||||
},
|
||||
// Время входа в рубеж — сторож застревания. Ставится только сменой рубежа
|
||||
// и возвратом записи в работу; откладывание опроса его не двигает.
|
||||
&core.DateField{Name: "state_entered_at"},
|
||||
// Остановка — признак, а не рубеж: `state` при ней не стирается, и снятие
|
||||
// признака продолжает работу с места остановки.
|
||||
&core.DateField{Name: "halted_at"},
|
||||
&core.SelectField{
|
||||
Name: "halt_reason",
|
||||
Values: entity.AllHaltReasons(),
|
||||
MaxSelect: 1,
|
||||
},
|
||||
&core.TextField{Name: "error_text"},
|
||||
// Признак **этого** захвата: значение уникально для каждого захвата, и
|
||||
// запись результата условна по нему, а не по занятости записи.
|
||||
&core.TextField{Name: "acquisition_id"},
|
||||
// Срок протухания захвата приезжает с рубежом и пишется числом при самом
|
||||
// захвате: воркер не привязан к шагу и вывести срок из себя не может.
|
||||
&core.DateField{Name: "acquire_expires_at"},
|
||||
&core.DateField{Name: "delay_time"},
|
||||
// Число отказов ограничивает повторы внутри шага. Время в рубеже мерит
|
||||
// отдельный сторож: одно число не справлялось ни с одной из обязанностей.
|
||||
&core.NumberField{Name: "attempts", OnlyInt: true, Min: ptr(0.0)},
|
||||
&core.RelationField{Name: "original_file", CollectionId: filesID, MaxSelect: 1},
|
||||
&core.RelationField{Name: "normalized_file", CollectionId: filesID, MaxSelect: 1},
|
||||
&core.RelationField{
|
||||
Name: "topics",
|
||||
CollectionId: topicsID,
|
||||
MaxSelect: entity.MaxTopicsPerRecord,
|
||||
},
|
||||
&core.NumberField{Name: "tg_chat_id", OnlyInt: true},
|
||||
&core.NumberField{Name: "tg_reply_message_id", OnlyInt: true},
|
||||
&core.AutodateField{Name: "created", OnCreate: true},
|
||||
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
|
||||
)
|
||||
|
||||
// Отбор захвата идёт по рубежу, признаку остановки, паузе и сроку протухания
|
||||
// захвата — индекс снимает полный перебор.
|
||||
records.AddIndex("idx_audio_records_state", false, "state, halted_at", "")
|
||||
|
||||
if err := app.Save(records); err != nil {
|
||||
return nil, fmt.Errorf("failed to create audio records: %w", err)
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// createTexts заводит тексты записи. Пара «запись и вид» уникальна: повтор
|
||||
// прерванного шага иначе завёл бы второй комплект строк, и вопрос «какой текст
|
||||
// отдавать человеку» стал бы вопросом порядка записи, а не состояния.
|
||||
func createTexts(app core.App, recordsID string) (*core.Collection, error) {
|
||||
texts := core.NewBaseCollection(TextsCollection)
|
||||
texts.Fields.Add(
|
||||
&core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true},
|
||||
&core.SelectField{
|
||||
Name: "kind",
|
||||
Values: entity.AllTextKinds(),
|
||||
MaxSelect: 1,
|
||||
Required: true,
|
||||
},
|
||||
// Поле зовётся `kind`, а не `format`: словом `format` в этой же схеме
|
||||
// зовут формат файла, и третий смысл у одного слова развёл бы по разным
|
||||
// вещам вид текста и формат копии.
|
||||
&core.EditorField{Name: "contents"},
|
||||
&core.AutodateField{Name: "created", OnCreate: true},
|
||||
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
|
||||
)
|
||||
texts.AddIndex("idx_texts_record_kind", true, "record, kind", "")
|
||||
|
||||
if err := app.Save(texts); err != nil {
|
||||
return nil, fmt.Errorf("failed to create texts: %w", err)
|
||||
}
|
||||
return texts, nil
|
||||
}
|
||||
|
||||
// createStructures заводит структуру реплик. Номер версии нужен потому, что
|
||||
// разбор сохранённого ответа изменится раньше, чем архив пересчитают.
|
||||
func createStructures(app core.App, recordsID string) (*core.Collection, error) {
|
||||
structures := core.NewBaseCollection(StructuresCollection)
|
||||
structures.Fields.Add(
|
||||
&core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true},
|
||||
&core.NumberField{Name: "version", OnlyInt: true, Required: true},
|
||||
&core.JSONField{Name: "contents", MaxSize: structureContentsMaxSize},
|
||||
&core.AutodateField{Name: "created", OnCreate: true},
|
||||
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
|
||||
)
|
||||
structures.AddIndex("idx_structures_record_version", true, "record, version", "")
|
||||
|
||||
if err := app.Save(structures); err != nil {
|
||||
return nil, fmt.Errorf("failed to create structures: %w", err)
|
||||
}
|
||||
return structures, nil
|
||||
}
|
||||
|
||||
// createRecognitions заводит попытку распознавания у внешнего провайдера.
|
||||
//
|
||||
// Сырой ответ лежит **вложением**, а не колонкой: шаг опроса читает эту строку
|
||||
// раз в несколько секунд, а хранилище читает запись целиком — ответ на
|
||||
// многочасовую запись ехал бы в память при каждом опросе.
|
||||
//
|
||||
// Поле вложения помечено защищённым: сырой ответ это полный текст речи, и
|
||||
// умолчание библиотеки отдавало бы его по ссылке любому, кто её знает.
|
||||
func createRecognitions(app core.App, recordsID string) (*core.Collection, error) {
|
||||
recognitions := core.NewBaseCollection(RecognitionsCollection)
|
||||
payload := &core.FileField{Name: "payload", MaxSelect: 1, MaxSize: recognitionPayloadMaxSize}
|
||||
payload.Protected = true
|
||||
|
||||
recognitions.Fields.Add(
|
||||
&core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true},
|
||||
&core.TextField{Name: "provider", Required: true},
|
||||
&core.TextField{Name: "model"},
|
||||
// Идентификатор операции у провайдера — самое провайдерское, что есть в
|
||||
// модели, и живёт он здесь, а не колонкой записи.
|
||||
&core.TextField{Name: "external_id"},
|
||||
// Адрес, по которому провайдер читает аудио. Копия во внешнем хранилище
|
||||
// файлом записи не считается: другой провайдер её не потребует.
|
||||
&core.TextField{Name: "source_uri"},
|
||||
payload,
|
||||
&core.DateField{Name: "started_at"},
|
||||
&core.DateField{Name: "finished_at"},
|
||||
&core.AutodateField{Name: "created", OnCreate: true},
|
||||
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
|
||||
)
|
||||
|
||||
if err := app.Save(recognitions); err != nil {
|
||||
return nil, fmt.Errorf("failed to create recognitions: %w", err)
|
||||
}
|
||||
return recognitions, nil
|
||||
}
|
||||
|
||||
// createRecordEvents заводит журнал событий записи.
|
||||
//
|
||||
// Колонка текста отказа зовётся `outcome_text`, а не `error_text`: последнее имя
|
||||
// названо поимённо инвариантом проекта о секрете, и две колонки с этим именем
|
||||
// сделали бы инвариант двусмысленным.
|
||||
func createRecordEvents(app core.App, recordsID string) error {
|
||||
events := core.NewBaseCollection(RecordEventsCollection)
|
||||
events.Fields.Add(
|
||||
&core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true},
|
||||
&core.SelectField{
|
||||
Name: "origin",
|
||||
Values: entity.AllEventOrigins(),
|
||||
MaxSelect: 1,
|
||||
Required: true,
|
||||
},
|
||||
&core.TextField{Name: "step"},
|
||||
&core.SelectField{
|
||||
Name: "outcome",
|
||||
Values: entity.AllEventOutcomes(),
|
||||
MaxSelect: 1,
|
||||
Required: true,
|
||||
},
|
||||
&core.TextField{Name: "outcome_text"},
|
||||
&core.NumberField{Name: "duration_ms", OnlyInt: true},
|
||||
&core.AutodateField{Name: "created", OnCreate: true},
|
||||
)
|
||||
events.AddIndex("idx_record_events_record", false, "record", "")
|
||||
|
||||
if err := app.Save(events); err != nil {
|
||||
return fmt.Errorf("failed to create record events: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createTopics заводит словарь тем. Тема уникальна в паре «владелец и название»:
|
||||
// словарь свой у каждого человека, и общий показал бы одному темы другого.
|
||||
func createTopics(app core.App, usersID string) (*core.Collection, error) {
|
||||
topics := core.NewBaseCollection(TopicsCollection)
|
||||
topics.Fields.Add(
|
||||
&core.RelationField{
|
||||
Name: "owner",
|
||||
CollectionId: usersID,
|
||||
MaxSelect: 1,
|
||||
Required: true,
|
||||
},
|
||||
&core.TextField{Name: "name", Required: true},
|
||||
&core.AutodateField{Name: "created", OnCreate: true},
|
||||
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
|
||||
)
|
||||
topics.AddIndex("idx_topics_owner_name", true, "owner, name", "")
|
||||
|
||||
if err := app.Save(topics); err != nil {
|
||||
return nil, fmt.Errorf("failed to create topics: %w", err)
|
||||
}
|
||||
return topics, nil
|
||||
}
|
||||
|
||||
const (
|
||||
// structureContentsMaxSize — потолок разбитой на реплики расшифровки. Число
|
||||
// с запасом: шестичасовой разговор даёт порядка мегабайта текста с временем.
|
||||
structureContentsMaxSize = 16 << 20
|
||||
// recognitionPayloadMaxSize — потолок сохранённого ответа провайдера. Он
|
||||
// многословнее самой расшифровки: несёт альтернативы, время каждого слова и
|
||||
// разбор говорящих.
|
||||
recognitionPayloadMaxSize = 256 << 20
|
||||
)
|
||||
|
||||
// Поля объявляются россыпью, а не помощником, который принимал бы имя доводом:
|
||||
// сверка перечня колонок со схемой читает литерал `Name:` в шагах, и имя,
|
||||
// спрятанное за вызовом, она не видит — колонка выпала бы из-под правила молча.
|
||||
|
||||
// down202608140002 снимает новые коллекции. Прежнюю коллекцию задач он не
|
||||
// восстанавливает: данных под ней не было, а пустая копия прежней схемы была бы
|
||||
// вторым домом для понятия, которого больше нет.
|
||||
func down202608140002(app core.App) error {
|
||||
// Порядок обратный порядку заведения: приложения ссылаются на запись.
|
||||
order := []string{
|
||||
RecordEventsCollection,
|
||||
RecognitionsCollection,
|
||||
StructuresCollection,
|
||||
TextsCollection,
|
||||
RecordsCollection,
|
||||
TopicsCollection,
|
||||
}
|
||||
for _, name := range order {
|
||||
collection, err := app.FindCollectionByNameOrId(name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := app.Delete(collection); err != nil {
|
||||
return fmt.Errorf("failed to drop %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -23,7 +23,19 @@ import (
|
||||
// меняются только новым шагом схемы.
|
||||
const (
|
||||
FilesCollection = "files"
|
||||
JobsCollection = "transcribe_jobs"
|
||||
// JobsCollection — прежняя коллекция задач. Шаг 202608140002 её удаляет;
|
||||
// имя остаётся здесь, потому что на него ссылаются прежние шаги схемы, а
|
||||
// применённый шаг не переписывается.
|
||||
JobsCollection = "transcribe_jobs"
|
||||
// RecordsCollection — аудиозапись, центральная сущность сервиса. Имя в
|
||||
// snake_case, как у соседей по схеме: одно исключение разошлось бы молча по
|
||||
// константе имён, запросу захвата, правилам панели и запрету удаления.
|
||||
RecordsCollection = "audio_records"
|
||||
TextsCollection = "texts"
|
||||
StructuresCollection = "structures"
|
||||
RecognitionsCollection = "recognitions"
|
||||
RecordEventsCollection = "record_events"
|
||||
TopicsCollection = "topics"
|
||||
// UsersCollection заводит не наш шаг, а системный шаг библиотеки. Имя стоит
|
||||
// здесь потому, что на него ссылаются и шаги схемы, и проверка предъявителя
|
||||
// на приёме: строковый литерал в двух местах разошёлся бы молча.
|
||||
@@ -36,6 +48,7 @@ func init() {
|
||||
pbmigrations.Register(up202608110001, down202608110001, "202608110001_init.go")
|
||||
pbmigrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go")
|
||||
pbmigrations.Register(up202608140001, down202608140001, "202608140001_record_owner.go")
|
||||
pbmigrations.Register(up202608140002, down202608140002, "202608140002_record_centric_model.go")
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
// GuardOwnerDeletion отвергает удаление учётной записи, у которой остались
|
||||
// задачи расшифровки.
|
||||
// аудиозаписи, их файлы либо темы её словаря.
|
||||
//
|
||||
// Колонка владельца — связь с выключенным каскадным удалением, и одного этого
|
||||
// мало: при выключенном каскаде хранилище не удаляет ссылающуюся запись, а
|
||||
@@ -29,10 +29,11 @@ import (
|
||||
// только панельное — умолчание библиотеки разрешает вошедшему удалить свою
|
||||
// учётную запись запросом, так что страж закрывает и публичную поверхность.
|
||||
//
|
||||
// Считаются **обе** коллекции с владельцем. Файл переживает свою задачу: шаг
|
||||
// конвейера заводит его до сохранения задачи, и потерянный захват оставляет файл
|
||||
// с владельцем и без ссылки. Учётная запись, у которой остались одни такие
|
||||
// файлы, без этого счёта удалялась бы штатно, а аудио становилось бы ничьим.
|
||||
// Считаются **все** коллекции с владельцем, и перечень их живёт одним списком
|
||||
// ниже. Файл переживает свою запись: шаг конвейера заводит его до сохранения, и
|
||||
// потерянный захват оставляет файл с владельцем и без ссылки. Учётная запись, у
|
||||
// которой остались одни такие файлы, без этого счёта удалялась бы штатно, а
|
||||
// аудио становилось бы ничьим.
|
||||
func GuardOwnerDeletion(app core.App) {
|
||||
app.OnRecordDelete(migrations.UsersCollection).BindFunc(func(e *core.RecordEvent) error {
|
||||
count, err := countOwned(e.App, e.Record.Id)
|
||||
@@ -62,13 +63,25 @@ func GuardOwnerDeletion(app core.App) {
|
||||
})
|
||||
}
|
||||
|
||||
// countOwned считает всё, что принадлежит учётной записи, — по обеим коллекциям
|
||||
// с колонкой владельца. Перечень живёт здесь одним списком: разойдясь с шагом
|
||||
// схемы, он оставил бы половину архива без защиты молча.
|
||||
// ownedCollections — коллекции с колонкой владельца. Перечень живёт здесь одним
|
||||
// списком, и разойтись с шагом схемы ему нельзя: пропущенная коллекция
|
||||
// пропускает удаление вперёд, а наружу приезжает не наш отказ с причиной, а
|
||||
// подсказка библиотеки про обязательную связь — та самая, по которой владелец
|
||||
// панели пойдёт удалять записи руками.
|
||||
//
|
||||
// Так уже случилось однажды: `topics` завелась третьей и в списке не появилась.
|
||||
var ownedCollections = []string{
|
||||
migrations.RecordsCollection,
|
||||
migrations.FilesCollection,
|
||||
migrations.TopicsCollection,
|
||||
}
|
||||
|
||||
// countOwned считает всё, что принадлежит учётной записи, — по всем коллекциям
|
||||
// с колонкой владельца.
|
||||
func countOwned(app core.App, ownerID string) (int64, error) {
|
||||
var total int64
|
||||
|
||||
for _, collection := range []string{migrations.JobsCollection, migrations.FilesCollection} {
|
||||
for _, collection := range ownedCollections {
|
||||
count, err := app.CountRecords(collection, dbx.HashExp{"owner": ownerID})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count owned records in %s: %w", collection, err)
|
||||
|
||||
@@ -4,179 +4,134 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// newAccount заводит учётную запись и отдаёт её идентификатор.
|
||||
func newAccount(t *testing.T, app core.App, email string) string {
|
||||
// Страж удаления учётной записи — единственное, что стоит между владельцем
|
||||
// панели и молчаливым обезличиванием чужого архива: при выключенном каскаде
|
||||
// хранилище снимает ссылку и сохраняет запись без проверок.
|
||||
|
||||
func newAccount(t *testing.T, app core.App) *core.Record {
|
||||
t.Helper()
|
||||
|
||||
users, err := app.FindCollectionByNameOrId(migrations.UsersCollection)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := core.NewRecord(users)
|
||||
record.Set("email", email)
|
||||
record.Set("email", uuid.NewString()+"@example.test")
|
||||
record.Set("verified", true)
|
||||
record.SetRandomPassword()
|
||||
record.Set("password", uuid.NewString())
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
return record.Id
|
||||
return record
|
||||
}
|
||||
|
||||
// Колонка владельца заводится шагом схемы на чистой базе, и умолчания у неё нет.
|
||||
func TestOwnerColumnHasNoDefault(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
// newRecordOf заводит аудиозапись названного владельца.
|
||||
func newRecordOf(t *testing.T, app core.App, ownerID string) *entity.AudioRecord {
|
||||
t.Helper()
|
||||
|
||||
for _, name := range []string{migrations.JobsCollection, migrations.FilesCollection} {
|
||||
collection, err := app.FindCollectionByNameOrId(name)
|
||||
require.NoError(t, err)
|
||||
|
||||
field := collection.Fields.GetByName("owner")
|
||||
require.NotNil(t, field, "колонка владельца заведена в %s", name)
|
||||
|
||||
relation, ok := field.(*core.RelationField)
|
||||
require.True(t, ok, "владелец — связь с учётной записью, а не строка")
|
||||
assert.False(t, relation.Required, "пустое значение допустимо ради записей бота")
|
||||
assert.False(t, relation.CascadeDelete, "удаление учётной записи не уносит записи")
|
||||
|
||||
// Умолчания у связи нет по устройству: запись, чей владелец не назван,
|
||||
// не достаётся никому по недосмотру схемы.
|
||||
record := core.NewRecord(collection)
|
||||
assert.Empty(t, record.GetString("owner"), "новая запись приходит без владельца")
|
||||
record := &entity.AudioRecord{
|
||||
State: entity.StateUploaded,
|
||||
StateEnteredAt: clock.Now(),
|
||||
Source: entity.SourceApi,
|
||||
}
|
||||
}
|
||||
|
||||
// Чужая задача, ничья и несуществующая дают одну и ту же ошибку.
|
||||
func TestGetByID_NarrowedByOwner(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
|
||||
mine := newAccount(t, app, "mine@example.com")
|
||||
stranger := newAccount(t, app, "stranger@example.com")
|
||||
|
||||
owned := newJob(t, repo, entity.StateCreated)
|
||||
owned.OwnerID = &mine
|
||||
require.NoError(t, repo.Save(owned, ""))
|
||||
// Владельца кладёт заведение, а не сохранение конвейера, — ставим его прямо.
|
||||
record, err := app.FindRecordById(migrations.JobsCollection, owned.Id)
|
||||
require.NoError(t, err)
|
||||
record.Set("owner", mine)
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
ownerless := newJob(t, repo, entity.StateCreated)
|
||||
|
||||
got, err := repo.GetByID(owned.Id, mine)
|
||||
require.NoError(t, err, "своя задача отдаётся")
|
||||
assert.Equal(t, owned.Id, got.Id)
|
||||
|
||||
_, foreignErr := repo.GetByID(owned.Id, stranger)
|
||||
_, ownerlessErr := repo.GetByID(ownerless.Id, mine)
|
||||
_, missingErr := repo.GetByID("nonexistent0000", mine)
|
||||
|
||||
var notFound *contract.JobNotFoundError
|
||||
require.ErrorAs(t, foreignErr, ¬Found, "чужая задача не отдаётся")
|
||||
require.ErrorAs(t, ownerlessErr, ¬Found, "ничья задача не отдаётся")
|
||||
require.ErrorAs(t, missingErr, ¬Found, "несуществующая тоже")
|
||||
}
|
||||
|
||||
// Пустой владелец не совпадает ни с чем: ни со своей задачей, ни с чужой, ни с
|
||||
// ничьей. Правило записано со стороны спрашивающего — обязательность, которую
|
||||
// держит одна лишь подпись метода, пустую строку пропускает.
|
||||
func TestGetByID_EmptyOwnerMatchesNothing(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
|
||||
owner := newAccount(t, app, "mine@example.com")
|
||||
|
||||
owned := newJob(t, repo, entity.StateCreated)
|
||||
record, err := app.FindRecordById(migrations.JobsCollection, owned.Id)
|
||||
require.NoError(t, err)
|
||||
record.Set("owner", owner)
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
ownerless := newJob(t, repo, entity.StateCreated)
|
||||
|
||||
var notFound *contract.JobNotFoundError
|
||||
for _, id := range []string{owned.Id, ownerless.Id, "nonexistent0000"} {
|
||||
_, err := repo.GetByID(id, "")
|
||||
require.ErrorAs(t, err, ¬Found, "пустой владелец не открывает %s", id)
|
||||
if ownerID != "" {
|
||||
record.OwnerID = &ownerID
|
||||
}
|
||||
require.NoError(t, NewAudioRecordRepository(app).Create(record))
|
||||
return record
|
||||
}
|
||||
|
||||
// Удаление учётной записи с задачами отвергается: связь с выключенным каскадом
|
||||
// иначе снимает ссылку, и архив человека становится ничьим и недостижимым.
|
||||
// Учётная запись с архивом не удаляется, и отказ называет причину — иначе
|
||||
// наружу приезжает подсказка библиотеки про обязательную связь, по которой
|
||||
// владелец панели пойдёт удалять записи руками.
|
||||
func TestGuardOwnerDeletion(t *testing.T) {
|
||||
// Страж вешает сама сборка хранилища — звать его отдельно не нужно и нельзя:
|
||||
// второй вызов повесил бы второй слой.
|
||||
app := newTestApp(t)
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
app := newTestStorage(t)
|
||||
|
||||
withJobs := newAccount(t, app, "keeper@example.com")
|
||||
empty := newAccount(t, app, "empty@example.com")
|
||||
account := newAccount(t, app)
|
||||
record := newRecordOf(t, app, account.Id)
|
||||
|
||||
job := newJob(t, repo, entity.StateCreated)
|
||||
record, err := app.FindRecordById(migrations.JobsCollection, job.Id)
|
||||
require.NoError(t, err)
|
||||
record.Set("owner", withJobs)
|
||||
require.NoError(t, app.Save(record))
|
||||
err := app.Delete(account)
|
||||
require.Error(t, err, "учётная запись с архивом не удаляется")
|
||||
assert.Contains(t, err.Error(), "остались записи", "отказ называет причину")
|
||||
|
||||
keeper, err := app.FindRecordById(migrations.UsersCollection, withJobs)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = app.Delete(keeper)
|
||||
require.Error(t, err, "учётная запись с задачами не удаляется")
|
||||
|
||||
// Отказ обязан быть ошибкой роутера, а не обычной: наружу библиотека
|
||||
// пропускает только её, а всякую другую подменяет своим сообщением про
|
||||
// обязательную связь — подсказкой, по которой владелец панели пойдёт удалять
|
||||
// задачи и файлы руками. Проверяется поэтому тип и текст, а не сам факт
|
||||
// отказа: на потерянном сообщении факт остаётся прежним.
|
||||
var apiErr *router.ApiError
|
||||
require.ErrorAs(t, err, &apiErr, "отказ доезжает до владельца панели")
|
||||
assert.Contains(t, apiErr.Message, "остались записи",
|
||||
"причина названа, а не подменена библиотечной")
|
||||
|
||||
// Задача и её владелец остались прежними.
|
||||
after, err := app.FindRecordById(migrations.JobsCollection, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, withJobs, after.GetString("owner"), "владелец не снят")
|
||||
|
||||
free, err := app.FindRecordById(migrations.UsersCollection, empty)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, app.Delete(free), "учётная запись без записей удаляется")
|
||||
after, err := NewAudioRecordRepository(app).Get(record.Id)
|
||||
require.NoError(t, err, "запись на месте")
|
||||
require.NotNil(t, after.OwnerID, "и владелец у неё прежний")
|
||||
assert.Equal(t, account.Id, *after.OwnerID)
|
||||
}
|
||||
|
||||
// Файл переживает свою задачу: шаг конвейера заводит его до сохранения задачи, и
|
||||
// потерянный захват оставляет файл с владельцем и без ссылки. Считать одни
|
||||
// задачи значило бы отдать такое аудио на молчаливое обезличивание.
|
||||
func TestGuardOwnerDeletion_CountsFilesToo(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
repo := NewFileRepository(app)
|
||||
// Считаются все коллекции с владельцем, а не одни записи: файл переживает свою
|
||||
// запись, а тема живёт в словаре человека.
|
||||
func TestGuardOwnerDeletionCountsEveryOwnedCollection(t *testing.T) {
|
||||
cases := map[string]func(t *testing.T, app core.App, ownerID string){
|
||||
"аудиозапись": func(t *testing.T, app core.App, ownerID string) {
|
||||
newRecordOf(t, app, ownerID)
|
||||
},
|
||||
"один файл без записи": func(t *testing.T, app core.App, ownerID string) {
|
||||
repo := NewFileRepository(app)
|
||||
work, err := repo.Stage(".mp3", strings.NewReader("запись"))
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, work.Close()) }()
|
||||
|
||||
owner := newAccount(t, app, "files@example.com")
|
||||
_, err = repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, ownerID)
|
||||
require.NoError(t, err)
|
||||
},
|
||||
"одна тема словаря": func(t *testing.T, app core.App, ownerID string) {
|
||||
topics, err := app.FindCollectionByNameOrId(migrations.TopicsCollection)
|
||||
require.NoError(t, err)
|
||||
|
||||
work, err := repo.Stage(".ogg", strings.NewReader("запись"))
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, work.Close()) }()
|
||||
topic := core.NewRecord(topics)
|
||||
topic.Set("owner", ownerID)
|
||||
topic.Set("name", "личная тема")
|
||||
require.NoError(t, app.Save(topic))
|
||||
},
|
||||
}
|
||||
|
||||
_, err = repo.CreateLocal("sample.ogg", work, owner)
|
||||
require.NoError(t, err)
|
||||
for name, own := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
account := newAccount(t, app)
|
||||
own(t, app, account.Id)
|
||||
|
||||
record, err := app.FindRecordById(migrations.UsersCollection, owner)
|
||||
err := app.Delete(account)
|
||||
require.Error(t, err, "учётная запись с этим добром не удаляется")
|
||||
assert.Contains(t, err.Error(), "остались записи",
|
||||
"отказ наш, а не подсказка библиотеки про обязательную связь")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Учётная запись, за которой ничего не числится, удаляется штатно: страж
|
||||
// заведён против потери архива, а не против удаления вообще.
|
||||
func TestGuardOwnerDeletionLetsEmptyAccountGo(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
account := newAccount(t, app)
|
||||
require.NoError(t, app.Delete(account), "пустая учётная запись удаляется")
|
||||
}
|
||||
|
||||
// Умолчания у колонки владельца нет: запись, чей владелец не назван, не
|
||||
// достаётся никому по недосмотру схемы.
|
||||
func TestOwnerColumnHasNoDefault(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
records, err := app.FindCollectionByNameOrId(migrations.RecordsCollection)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = app.Delete(record)
|
||||
require.Error(t, err, "учётная запись с одними файлами тоже не удаляется")
|
||||
field := records.Fields.GetByName("owner")
|
||||
require.NotNil(t, field, "колонка владельца заведена")
|
||||
|
||||
after, err := app.FindAllRecords(migrations.FilesCollection)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, after, 1)
|
||||
assert.Equal(t, owner, after[0].GetString("owner"), "владелец файла не снят")
|
||||
relation, ok := field.(*core.RelationField)
|
||||
require.True(t, ok, "владелец — связь с учётной записью, а не строка")
|
||||
assert.False(t, relation.Required, "пустое значение допустимо ради записей бота")
|
||||
assert.False(t, relation.CascadeDelete, "удаление учётной записи не уносит архив следом")
|
||||
}
|
||||
|
||||
@@ -4,35 +4,83 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// BindPanelRules подчиняет правку задачи в панели тем же правилам перехода, что
|
||||
// и правку из кода.
|
||||
// BindPanelRules подчиняет правку записи в панели тем же правилам, что и правку
|
||||
// из кода.
|
||||
//
|
||||
// Панель — вход в задачу наравне с конвейером, а не окно просмотра: ради правки
|
||||
// она и покупалась, мёртвая задача оживляется сменой состояния. Но правка полем
|
||||
// идёт мимо кода, который чистит служебные поля прошлого состояния, и владелец,
|
||||
// «вернувший задачу в работу», получил бы задачу с прежним признаком захвата
|
||||
// (захвату она не выдастся до конца срока) и с числом попыток на пределе (умрёт
|
||||
// от первого же отказа). Узнать об этом ему неоткуда.
|
||||
// Панель — вход в запись наравне с конвейером, а не окно просмотра: ради правки
|
||||
// она и покупалась, остановленная запись возвращается в работу снятием признака.
|
||||
// Но правка полем идёт мимо кода, который чистит служебные поля, и владелец,
|
||||
// «вернувший запись в работу», получил бы запись с прежним признаком захвата
|
||||
// (захвату она не выдастся до конца срока), с числом отказов на пределе
|
||||
// (остановится от первого же отказа) и со старым временем входа в рубеж
|
||||
// (остановится снова первым же захватом по пределу простоя). Узнать об этом ему
|
||||
// неоткуда.
|
||||
//
|
||||
// Правило живёт **одним местом** — доменными `Resume` и `MoveToState`, — и хук
|
||||
// зовёт именно их, а не повторяет перечень служебных полей колонками. Повтор
|
||||
// перечня был бы вторым домом того же правила: новый сторож попал бы в домен и
|
||||
// не попал в панель, и владелец «вернул бы запись в работу», а она снова выпала
|
||||
// бы из выборки — молча.
|
||||
//
|
||||
// Хук стоит на правке **запросом**, а не на всяком сохранении записи. Модельное
|
||||
// событие не различает, кто пишет, и срабатывало бы на каждом переходе
|
||||
// конвейера: тогда задержка, поставленная шагом вместе со сменой состояния,
|
||||
// стиралась бы тем же сохранением, а число попыток мёртвой задачи — которое
|
||||
// переход хранит намеренно — приходило бы владельцу нулём.
|
||||
// конвейера: тогда пауза, поставленная шагом вместе со сменой рубежа, стиралась
|
||||
// бы тем же сохранением, а число отказов остановленной записи — которое
|
||||
// остановка хранит намеренно — приходило бы владельцу нулём.
|
||||
func BindPanelRules(app core.App) {
|
||||
app.OnRecordUpdateRequest(migrations.JobsCollection).BindFunc(func(e *core.RecordRequestEvent) error {
|
||||
app.OnRecordUpdateRequest(migrations.RecordsCollection).BindFunc(func(e *core.RecordRequestEvent) error {
|
||||
original := e.Record.Original()
|
||||
if original == nil || original.GetString("state") == e.Record.GetString("state") {
|
||||
if original == nil {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
e.Record.Set("acquisition_id", "")
|
||||
e.Record.Set("acquire_time", "")
|
||||
e.Record.Set("delay_time", "")
|
||||
e.Record.Set("attempts", 0)
|
||||
stateChanged := original.GetString("state") != e.Record.GetString("state")
|
||||
// Снятие признака остановки — то самое движение, ради которого признак и
|
||||
// заведён: запись возвращается в работу с сохранённого рубежа.
|
||||
resumed := !original.GetDateTime("halted_at").IsZero() &&
|
||||
e.Record.GetDateTime("halted_at").IsZero()
|
||||
|
||||
return e.Next()
|
||||
if !stateChanged && !resumed {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
// Запись читается уже с правкой человека: рубеж здесь тот, который он
|
||||
// выбрал, а признак остановки — тот, который он снял или оставил.
|
||||
record := recordToAudioRecord(e.Record)
|
||||
switch {
|
||||
case resumed:
|
||||
record.Resume()
|
||||
default:
|
||||
record.MoveToState(record.State)
|
||||
}
|
||||
applyOwnedByPipeline(e.Record, record)
|
||||
|
||||
if err := e.Next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resumed {
|
||||
// Перезапуск виден в журнале событий с указанием, что его сделал
|
||||
// человек: иначе запись, вернувшаяся в работу, выглядела бы как
|
||||
// запись, которая туда и не уходила.
|
||||
//
|
||||
// Строка пишется **после** сохранения: событие о правке, которая не
|
||||
// прошла, соврало бы о состоянии записи. Отказ записи журнала саму
|
||||
// правку не отменяет — журнал никем не читается ради решения.
|
||||
event := &entity.RecordEvent{
|
||||
RecordID: e.Record.Id,
|
||||
Origin: entity.EventOriginHuman,
|
||||
Step: "resume",
|
||||
Outcome: entity.EventOutcomeResumed,
|
||||
}
|
||||
if err := NewRecordEventRepository(e.App).Append(event); err != nil {
|
||||
e.App.Logger().Error("Failed to log record resume", "error", err, "record_id", e.Record.Id)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Панель — единственный сегодня путь вернуть остановленную запись в работу, и
|
||||
// хук правил стоит на правке **запросом**. Модельное сохранение его не трогает,
|
||||
// поэтому проверки ниже идут через запрос — иначе они зеленели бы, не касаясь
|
||||
// того пути, которым владелец и ходит.
|
||||
|
||||
// newPanelStorage поднимает хранилище с повешенными правилами панели — так же,
|
||||
// как это делает сборка сервиса. Без них проверки судили бы хранилище без
|
||||
// правил, то есть не то, что работает в проде.
|
||||
func newPanelStorage(t *testing.T) core.App {
|
||||
t.Helper()
|
||||
|
||||
app := newTestStorage(t)
|
||||
BindPanelRules(app)
|
||||
return app
|
||||
}
|
||||
|
||||
// updateByRequest правит запись так, как это делает панель: запросом, а не
|
||||
// сохранением модели.
|
||||
func updateByRequest(t *testing.T, app core.App, recordID string, body map[string]any) *core.Record {
|
||||
t.Helper()
|
||||
|
||||
record, err := app.FindRecordById(migrations.RecordsCollection, recordID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Запись, прочитанная из хранилища, помнит прежние значения сама — по ним
|
||||
// хук и отличает смену рубежа от правки соседнего поля.
|
||||
for key, value := range body {
|
||||
record.Set(key, value)
|
||||
}
|
||||
|
||||
// Событие правки запросом несёт и запрос, и коллекцию: `RequestEvent` вложен
|
||||
// указателем, а по коллекции хук и отбирается — без неё он не сработает вовсе,
|
||||
// и проверка зеленела бы, не коснувшись правила.
|
||||
collection, err := app.FindCollectionByNameOrId(migrations.RecordsCollection)
|
||||
require.NoError(t, err)
|
||||
|
||||
event := &core.RecordRequestEvent{RequestEvent: &core.RequestEvent{}}
|
||||
event.App = app
|
||||
event.Collection = collection
|
||||
event.Record = record
|
||||
|
||||
require.NoError(t, app.OnRecordUpdateRequest(migrations.RecordsCollection).Trigger(event, func(e *core.RecordRequestEvent) error {
|
||||
return e.App.Save(e.Record)
|
||||
}))
|
||||
|
||||
after, err := app.FindRecordById(migrations.RecordsCollection, recordID)
|
||||
require.NoError(t, err)
|
||||
return after
|
||||
}
|
||||
|
||||
// haltedRecord заводит остановленную запись со всеми накопленными сторожами —
|
||||
// такой её видит владелец, открывая панель.
|
||||
func haltedRecord(t *testing.T, app core.App) *entity.AudioRecord {
|
||||
t.Helper()
|
||||
|
||||
record := newRecordOf(t, app, "")
|
||||
record.MoveToState(entity.StateNormalized)
|
||||
record.Attempts = 4
|
||||
record.AcquisitionID = ptrOf("прежний-захват")
|
||||
record.AcquireExpiresAt = ptrOf(clock.Now().Add(8 * time.Hour))
|
||||
record.DelayTime = ptrOf(clock.Now().Add(time.Hour))
|
||||
record.Halt(entity.HaltReasonStepFailed, "сбой конвертации файла")
|
||||
// Время входа в рубеж отодвигаем: запись простояла остановленной дольше
|
||||
// предела простоя, и это ровно тот случай, ради которого сторож сбрасывается.
|
||||
record.StateEnteredAt = clock.Now().Add(-24 * time.Hour)
|
||||
|
||||
require.NoError(t, NewAudioRecordRepository(app).Save(record, ""))
|
||||
return record
|
||||
}
|
||||
|
||||
func ptrOf[T any](v T) *T { return &v } //nolint:newexpr // значение вычисляется, new(x) его не примет
|
||||
|
||||
// Снятие признака остановки возвращает запись в работу с сохранённого рубежа и
|
||||
// сбрасывает **всех** сторожей. Без сброса времени входа в рубеж запись,
|
||||
// простоявшая остановленной дольше предела, остановилась бы снова первым же
|
||||
// захватом — и владелец не узнал бы об этом.
|
||||
func TestPanelResumeClearsEveryGuard(t *testing.T) {
|
||||
app := newPanelStorage(t)
|
||||
record := haltedRecord(t, app)
|
||||
|
||||
after := updateByRequest(t, app, record.Id, map[string]any{"halted_at": ""})
|
||||
|
||||
assert.Equal(t, entity.StateNormalized, after.GetString("state"), "рубеж сохранён")
|
||||
assert.True(t, after.GetDateTime("halted_at").IsZero(), "признак остановки снят")
|
||||
assert.Empty(t, after.GetString("halt_reason"), "причина снята вместе с ним")
|
||||
assert.Empty(t, after.GetString("error_text"), "и текст отказа")
|
||||
assert.Empty(t, after.GetString("acquisition_id"), "признак прежнего захвата очищен")
|
||||
assert.True(t, after.GetDateTime("acquire_expires_at").IsZero(), "срок протухания тоже")
|
||||
assert.True(t, after.GetDateTime("delay_time").IsZero(), "пауза снята")
|
||||
assert.Equal(t, 0, after.GetInt("attempts"), "отказы сброшены")
|
||||
|
||||
entered := after.GetDateTime("state_entered_at").Time()
|
||||
assert.WithinDuration(t, clock.Now(), entered, time.Minute,
|
||||
"время входа в рубеж поставлено заново: иначе сторож простоя остановит запись снова")
|
||||
|
||||
// И ближайший захват её выдаёт — то есть перезапуск действительно работает.
|
||||
acquired, err := NewAudioRecordRepository(app).FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err, "запись вернулась в выборку")
|
||||
assert.Equal(t, record.Id, acquired.ID)
|
||||
}
|
||||
|
||||
// Перезапуск виден в журнале событий с указанием, что его сделал человек: иначе
|
||||
// запись, вернувшаяся в работу, выглядела бы как запись, которая туда и не
|
||||
// уходила.
|
||||
func TestPanelResumeIsLogged(t *testing.T) {
|
||||
app := newPanelStorage(t)
|
||||
record := haltedRecord(t, app)
|
||||
|
||||
updateByRequest(t, app, record.Id, map[string]any{"halted_at": ""})
|
||||
|
||||
events, err := app.FindAllRecords(migrations.RecordEventsCollection)
|
||||
require.NoError(t, err)
|
||||
|
||||
var human int
|
||||
for _, event := range events {
|
||||
if event.GetString("record") == record.Id && event.GetString("origin") == entity.EventOriginHuman {
|
||||
human++
|
||||
assert.Equal(t, entity.EventOutcomeResumed, event.GetString("outcome"))
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, human, "ровно одна строка о перезапуске человеком")
|
||||
}
|
||||
|
||||
// Правка рубежа руками чистит служебные поля прошлого захвата так же, как
|
||||
// снятие остановки: иначе владелец, «вернувший запись в работу» сменой рубежа,
|
||||
// получит запись, которая не выдаётся захвату до конца прежнего срока.
|
||||
func TestPanelStateEditClearsGuards(t *testing.T) {
|
||||
app := newPanelStorage(t)
|
||||
|
||||
record := newRecordOf(t, app, "")
|
||||
record.Attempts = 4
|
||||
record.AcquisitionID = ptrOf("прежний-захват")
|
||||
record.AcquireExpiresAt = ptrOf(clock.Now().Add(8 * time.Hour))
|
||||
require.NoError(t, NewAudioRecordRepository(app).Save(record, ""))
|
||||
|
||||
after := updateByRequest(t, app, record.Id, map[string]any{"state": entity.StateNormalized})
|
||||
|
||||
assert.Equal(t, entity.StateNormalized, after.GetString("state"))
|
||||
assert.Empty(t, after.GetString("acquisition_id"))
|
||||
assert.Equal(t, 0, after.GetInt("attempts"))
|
||||
}
|
||||
|
||||
// Правка соседнего поля служебных полей не трогает: хук судит смену рубежа и
|
||||
// снятие остановки, а не всякое сохранение. Иначе владелец, поправивший
|
||||
// заголовок, снял бы захват у работающего шага.
|
||||
func TestPanelKeepsGuardsOnUnrelatedEdit(t *testing.T) {
|
||||
app := newPanelStorage(t)
|
||||
|
||||
record := newRecordOf(t, app, "")
|
||||
record.Attempts = 3
|
||||
record.AcquisitionID = ptrOf("живой-захват")
|
||||
require.NoError(t, NewAudioRecordRepository(app).Save(record, ""))
|
||||
|
||||
after := updateByRequest(t, app, record.Id, map[string]any{"title": "Разговор с бабушкой"})
|
||||
|
||||
assert.Equal(t, "Разговор с бабушкой", after.GetString("title"))
|
||||
assert.Equal(t, "живой-захват", after.GetString("acquisition_id"), "захват работающего шага не снят")
|
||||
assert.Equal(t, 3, after.GetInt("attempts"), "отказы не сброшены")
|
||||
}
|
||||
|
||||
// Захват отдаёт идентификатор и признак **этого** захвата, а срок протухания
|
||||
// приезжает с рубежом: воркер не привязан к шагу и вывести срок из себя не
|
||||
// может.
|
||||
func TestAcquireCarriesStageDeadline(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
repo := NewAudioRecordRepository(app)
|
||||
|
||||
record := newRecordOf(t, app, "")
|
||||
|
||||
acquired, err := repo.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record.Id, acquired.ID)
|
||||
require.NotEmpty(t, acquired.Holder)
|
||||
|
||||
stored, err := app.FindRecordById(migrations.RecordsCollection, record.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, acquired.Holder, stored.GetString("acquisition_id"))
|
||||
|
||||
stage, ok := entity.StageByName(entity.StateUploaded)
|
||||
require.True(t, ok)
|
||||
expected := clock.Now().Add(stage.AcquireTimeout)
|
||||
assert.WithinDuration(t, expected, stored.GetDateTime("acquire_expires_at").Time(), time.Minute,
|
||||
"срок протухания приехал с рубежа записи")
|
||||
}
|
||||
|
||||
// Одна запись достаётся ровно одному захвату: на этом стоит инвариант «Принятая
|
||||
// запись не теряется молча».
|
||||
func TestAcquireHandsRecordToExactlyOne(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
repo := NewAudioRecordRepository(app)
|
||||
|
||||
newRecordOf(t, app, "")
|
||||
|
||||
first, err := repo.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err, "первому запись досталась")
|
||||
require.NotEmpty(t, first.Holder)
|
||||
|
||||
for range 2 {
|
||||
_, err = repo.FindAndAcquire(entity.WorkingStages())
|
||||
require.Error(t, err, "остальным — признак «работы нет»")
|
||||
}
|
||||
}
|
||||
|
||||
// Протухший захват возвращает запись в работу, и признак нового захвата
|
||||
// отличается от прежнего: условие записи результата сверяет именно значение.
|
||||
func TestRottenAcquisitionIsHandedOutAgain(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
repo := NewAudioRecordRepository(app)
|
||||
|
||||
record := newRecordOf(t, app, "")
|
||||
|
||||
first, err := repo.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err)
|
||||
|
||||
stored, err := app.FindRecordById(migrations.RecordsCollection, record.Id)
|
||||
require.NoError(t, err)
|
||||
stored.Set("acquire_expires_at", types.NowDateTime().Add(-time.Hour))
|
||||
require.NoError(t, app.Save(stored))
|
||||
|
||||
second, err := repo.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err, "протухший захват не мешает выдать запись следующему")
|
||||
assert.Equal(t, record.Id, second.ID)
|
||||
assert.NotEqual(t, first.Holder, second.Holder, "признак нового захвата отличается от прежнего")
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
type RecognitionRepository struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
func NewRecognitionRepository(app core.App) *RecognitionRepository {
|
||||
return &RecognitionRepository{app: app}
|
||||
}
|
||||
|
||||
// Create заводит строку попытки **до** обращения к провайдеру.
|
||||
//
|
||||
// Порядок здесь несущий: окно между ответом провайдера и записью идентификатора
|
||||
// операции — то место, где теряется оплаченное. Заведённая заранее строка даёт
|
||||
// повторному шагу, чем проверить сделанное прежде, чем платить второй раз.
|
||||
func (repo *RecognitionRepository) Create(r *entity.Recognition) error {
|
||||
collection, err := findCollection(repo.app, migrations.RecognitionsCollection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
started := clock.Now()
|
||||
record := core.NewRecord(collection)
|
||||
record.Set("record", r.RecordID)
|
||||
record.Set("provider", r.Provider)
|
||||
record.Set("model", r.Model)
|
||||
record.Set("external_id", r.ExternalID)
|
||||
record.Set("source_uri", r.SourceURI)
|
||||
record.Set("started_at", dateOrEmpty(&started))
|
||||
|
||||
if err := repo.app.Save(record); err != nil {
|
||||
return fmt.Errorf("failed to create recognition attempt for record %s: %w", r.RecordID, err)
|
||||
}
|
||||
|
||||
r.Id = record.Id
|
||||
r.StartedAt = &started
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submitted сохраняет адрес аудио и идентификатор заведённой операции. По
|
||||
// последнему повторный шаг узнаёт, что за эту запись уже заплачено, и второй раз
|
||||
// наружу не платит.
|
||||
func (repo *RecognitionRepository) Submitted(id, sourceURI, externalID string) error {
|
||||
record, err := repo.app.FindRecordById(migrations.RecognitionsCollection, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find recognition attempt %s: %w", id, err)
|
||||
}
|
||||
record.Set("source_uri", sourceURI)
|
||||
record.Set("external_id", externalID)
|
||||
if err := repo.app.Save(record); err != nil {
|
||||
return fmt.Errorf("failed to store operation id of attempt %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Finish кладёт сырой ответ провайдера вложением и отмечает завершение.
|
||||
//
|
||||
// Вложением, а не колонкой: шаг опроса читает эту строку раз в несколько секунд,
|
||||
// а хранилище читает запись целиком — ответ на многочасовую запись ехал бы в
|
||||
// память при каждом опросе. Хранится он потому, что результат операции у
|
||||
// провайдера не переспрашивается.
|
||||
func (repo *RecognitionRepository) Finish(id string, raw []byte) error {
|
||||
record, err := repo.app.FindRecordById(migrations.RecognitionsCollection, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find recognition attempt %s: %w", id, err)
|
||||
}
|
||||
|
||||
if len(raw) > 0 {
|
||||
// Имя вложения задаём мы: умолчание хранилища строит его из имени
|
||||
// исходного файла, а имя, данное отправителем, в хранилище не попадает.
|
||||
payload, err := filesystem.NewFileFromBytes(raw, id+".payload")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prepare provider payload of attempt %s", id)
|
||||
}
|
||||
record.Set("payload", payload)
|
||||
}
|
||||
|
||||
finished := clock.Now()
|
||||
record.Set("finished_at", dateOrEmpty(&finished))
|
||||
|
||||
if err := repo.app.Save(record); err != nil {
|
||||
// Отказ хранилища несёт имя файла вложения целиком, а оно — последняя
|
||||
// часть ссылки: цепочка `%w` уехала бы в журнал вместе с ним.
|
||||
return fmt.Errorf("failed to store provider payload of attempt %s", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo *RecognitionRepository) GetByID(id string) (*entity.Recognition, error) {
|
||||
record, err := repo.app.FindRecordById(migrations.RecognitionsCollection, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get recognition attempt %s: %w", id, err)
|
||||
}
|
||||
|
||||
return &entity.Recognition{
|
||||
Id: record.Id,
|
||||
RecordID: record.GetString("record"),
|
||||
Provider: record.GetString("provider"),
|
||||
Model: record.GetString("model"),
|
||||
ExternalID: record.GetString("external_id"),
|
||||
SourceURI: record.GetString("source_uri"),
|
||||
StartedAt: timeOrNil(record.GetDateTime("started_at")),
|
||||
FinishedAt: timeOrNil(record.GetDateTime("finished_at")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ReadRaw отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда ответ
|
||||
// нужен: шаг опроса читает строку попытки без него.
|
||||
func (repo *RecognitionRepository) ReadRaw(id string) ([]byte, error) {
|
||||
record, err := repo.app.FindRecordById(migrations.RecognitionsCollection, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find recognition attempt %s: %w", id, err)
|
||||
}
|
||||
|
||||
names := record.GetStringSlice("payload")
|
||||
if len(names) == 0 {
|
||||
return nil, fmt.Errorf("recognition attempt %s has no stored payload", id)
|
||||
}
|
||||
|
||||
fsys, err := repo.app.NewFilesystem()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open storage filesystem: %w", err)
|
||||
}
|
||||
|
||||
reader, err := fsys.GetReader(record.BaseFilesPath() + "/" + names[0])
|
||||
if err != nil {
|
||||
// Отказ хранилища несёт имя вложения целиком, а имя — последняя часть
|
||||
// ссылки на скачивание: наружу идёт идентификатор попытки, и только он.
|
||||
return nil, errors.Join(
|
||||
fmt.Errorf("failed to read stored payload of attempt %s", id),
|
||||
fsys.Close(),
|
||||
)
|
||||
}
|
||||
|
||||
raw, readErr := io.ReadAll(reader)
|
||||
closeErr := errors.Join(reader.Close(), fsys.Close())
|
||||
if readErr != nil {
|
||||
return nil, errors.Join(
|
||||
fmt.Errorf("failed to read stored payload of attempt %s", id),
|
||||
closeErr,
|
||||
)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, closeErr
|
||||
}
|
||||
|
||||
return raw, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
type RecordEventRepository struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
func NewRecordEventRepository(app core.App) *RecordEventRepository {
|
||||
return &RecordEventRepository{app: app}
|
||||
}
|
||||
|
||||
// Append пишет строку журнала событий записи.
|
||||
//
|
||||
// Журнал пишется на смену рубежа, на остановку и на снятие остановки, а не на
|
||||
// каждое откладывание опроса: часовая запись дала бы сотни строк ни о чём. Ни
|
||||
// один шаг конвейера его не читает, чтобы решить, что делать дальше: решение
|
||||
// принимается по рубежу записи, и второй источник решения разошёлся бы с первым
|
||||
// молча.
|
||||
//
|
||||
// Содержимое записи сюда не попадает — инвариант приватности действует здесь
|
||||
// наравне с журналом сервиса.
|
||||
func (repo *RecordEventRepository) Append(event *entity.RecordEvent) error {
|
||||
collection, err := findCollection(repo.app, migrations.RecordEventsCollection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
record.Set("record", event.RecordID)
|
||||
record.Set("origin", event.Origin)
|
||||
record.Set("step", event.Step)
|
||||
record.Set("outcome", event.Outcome)
|
||||
record.Set("outcome_text", event.OutcomeText)
|
||||
record.Set("duration_ms", event.DurationMs)
|
||||
|
||||
if err := repo.app.Save(record); err != nil {
|
||||
return fmt.Errorf("failed to append event of record %s: %w", event.RecordID, err)
|
||||
}
|
||||
|
||||
event.Id = record.Id
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Отображение аудиозаписи в запись коллекции и обратно живёт одним местом.
|
||||
//
|
||||
// Мест стало **два** вместо прежних четырёх: захват больше не перечисляет
|
||||
// колонки поимённо, а возвращает идентификатор и признак своего захвата.
|
||||
// Инвариант проекта о колонках очереди этим съёживается и перестаёт расти с
|
||||
// моделью — иначе каждая новая колонка записи попадала бы под него.
|
||||
|
||||
// applyOwnedByPipeline кладёт в запись только те поля, которыми распоряжается
|
||||
// конвейер. Поля, которые он не меняет никогда — владелец, вход, заголовок,
|
||||
// краткое описание, темы и адресат ответа, — не трогаются вовсе.
|
||||
//
|
||||
// Разрез нужен потому, что шаг держит запись снимком с момента захвата и до
|
||||
// своего сохранения, а это часы. Всё, что владелец правил в панели за это время,
|
||||
// безусловная запись снимка стёрла бы молча: ни строки в журнале, ни отказа в
|
||||
// панели — владелец видел бы успешное сохранение и был бы уверен, что правка на
|
||||
// месте.
|
||||
func applyOwnedByPipeline(record *core.Record, r *entity.AudioRecord) {
|
||||
record.Set("state", r.State)
|
||||
record.Set("state_entered_at", dateOrEmpty(&r.StateEnteredAt))
|
||||
record.Set("halted_at", dateOrEmpty(r.HaltedAt))
|
||||
record.Set("halt_reason", derefString(r.HaltReason))
|
||||
record.Set("error_text", derefString(r.ErrorText))
|
||||
record.Set("acquisition_id", derefString(r.AcquisitionID))
|
||||
record.Set("acquire_expires_at", dateOrEmpty(r.AcquireExpiresAt))
|
||||
record.Set("delay_time", dateOrEmpty(r.DelayTime))
|
||||
record.Set("attempts", r.Attempts)
|
||||
record.Set("original_file", derefString(r.OriginalFileID))
|
||||
record.Set("normalized_file", derefString(r.NormalizedFileID))
|
||||
record.Set("transcript_text", derefString(r.TranscriptTextID))
|
||||
record.Set("literary_text", derefString(r.LiteraryTextID))
|
||||
record.Set("structure", derefString(r.StructureID))
|
||||
record.Set("recognition", derefString(r.RecognitionID))
|
||||
}
|
||||
|
||||
// applyToRecord кладёт запись целиком — это заведение, и спорить за поля здесь
|
||||
// не с кем.
|
||||
func applyToRecord(record *core.Record, r *entity.AudioRecord) {
|
||||
applyOwnedByPipeline(record, r)
|
||||
// Владелец кладётся только здесь, при заведении. В applyOwnedByPipeline его
|
||||
// нет намеренно: конвейер владельца не назначает и не меняет, а снимок шага,
|
||||
// записанный поверх, стёр бы его молча.
|
||||
record.Set("owner", derefString(r.OwnerID))
|
||||
record.Set("source", r.Source)
|
||||
record.Set("title", derefString(r.Title))
|
||||
record.Set("brief", derefString(r.Brief))
|
||||
record.Set("tg_chat_id", derefInt64(r.TgChatId))
|
||||
record.Set("tg_reply_message_id", derefInt(r.TgReplyMessageId))
|
||||
}
|
||||
|
||||
func recordToAudioRecord(record *core.Record) *entity.AudioRecord {
|
||||
return &entity.AudioRecord{
|
||||
Id: record.Id,
|
||||
OwnerID: nilIfEmpty(record.GetString("owner")),
|
||||
Source: record.GetString("source"),
|
||||
Title: nilIfEmpty(record.GetString("title")),
|
||||
Brief: nilIfEmpty(record.GetString("brief")),
|
||||
State: record.GetString("state"),
|
||||
StateEnteredAt: record.GetDateTime("state_entered_at").Time(),
|
||||
HaltedAt: timeOrNil(record.GetDateTime("halted_at")),
|
||||
HaltReason: nilIfEmpty(record.GetString("halt_reason")),
|
||||
ErrorText: nilIfEmpty(record.GetString("error_text")),
|
||||
AcquisitionID: nilIfEmpty(record.GetString("acquisition_id")),
|
||||
AcquireExpiresAt: timeOrNil(record.GetDateTime("acquire_expires_at")),
|
||||
DelayTime: timeOrNil(record.GetDateTime("delay_time")),
|
||||
Attempts: record.GetInt("attempts"),
|
||||
OriginalFileID: nilIfEmpty(record.GetString("original_file")),
|
||||
NormalizedFileID: nilIfEmpty(record.GetString("normalized_file")),
|
||||
TranscriptTextID: nilIfEmpty(record.GetString("transcript_text")),
|
||||
LiteraryTextID: nilIfEmpty(record.GetString("literary_text")),
|
||||
StructureID: nilIfEmpty(record.GetString("structure")),
|
||||
RecognitionID: nilIfEmpty(record.GetString("recognition")),
|
||||
TgChatId: nilIfZero64(int64(record.GetInt("tg_chat_id"))),
|
||||
TgReplyMessageId: nilIfZeroInt(record.GetInt("tg_reply_message_id")),
|
||||
CreatedAt: record.GetDateTime("created").Time(),
|
||||
UpdatedAt: record.GetDateTime("updated").Time(),
|
||||
}
|
||||
}
|
||||
|
||||
func derefString(v *string) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
func derefInt64(v *int64) int64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
func derefInt(v *int) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// dateOrEmpty отдаёт пустое значение вместо нулевой даты: пустая колонка даты в
|
||||
// хранилище это пустая строка, и она же значит «времени нет».
|
||||
func dateOrEmpty(v *time.Time) any {
|
||||
if v == nil || v.IsZero() {
|
||||
return ""
|
||||
}
|
||||
date, err := types.ParseDateTime(*v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
func nilIfEmpty(v string) *string {
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
func timeOrNil(v types.DateTime) *time.Time {
|
||||
if v.IsZero() {
|
||||
return nil
|
||||
}
|
||||
t := v.Time()
|
||||
return &t
|
||||
}
|
||||
|
||||
func nilIfZero64(v int64) *int64 {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
func nilIfZeroInt(v int) *int {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
)
|
||||
|
||||
type AudioRecordRepository struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
func NewAudioRecordRepository(app core.App) *AudioRecordRepository {
|
||||
return &AudioRecordRepository{app: app}
|
||||
}
|
||||
|
||||
func (repo *AudioRecordRepository) Create(r *entity.AudioRecord) error {
|
||||
collection, err := findCollection(repo.app, migrations.RecordsCollection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
if r.Id != "" {
|
||||
record.Id = r.Id
|
||||
}
|
||||
applyToRecord(record, r)
|
||||
|
||||
if err := repo.app.Save(record); err != nil {
|
||||
return fmt.Errorf("failed to insert audio record: %w", err)
|
||||
}
|
||||
|
||||
r.Id = record.Id
|
||||
r.CreatedAt = record.GetDateTime("created").Time()
|
||||
r.UpdatedAt = record.GetDateTime("updated").Time()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save сохраняет запись, захват которой держит holder. Проверка и запись идут
|
||||
// одной транзакцией: шаг, потерявший запись за время работы, получает
|
||||
// LostAcquisitionError и результата не пишет.
|
||||
//
|
||||
// Сверяется **значение** признака захвата, а не занятость записи. Захват,
|
||||
// перевыданный другому — по протуханию срока или после того, как человек снял
|
||||
// признак остановки в панели, — обязан обратить запись первого в отказ; условие
|
||||
// по непустоте признака пропустило бы обоих, и два шага записали бы в одну
|
||||
// запись и оба ответили бы отправителю.
|
||||
func (repo *AudioRecordRepository) Save(r *entity.AudioRecord, holder string) error {
|
||||
return repo.app.RunInTransaction(func(txApp core.App) error {
|
||||
record, err := txApp.FindRecordById(migrations.RecordsCollection, r.Id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find audio record: %w", err)
|
||||
}
|
||||
|
||||
if holder != "" && record.GetString("acquisition_id") != holder {
|
||||
return &contract.LostAcquisitionError{JobID: r.Id}
|
||||
}
|
||||
|
||||
// Кладём только то, чем распоряжается конвейер: правку владельца в
|
||||
// панели снимок шага стирать не должен.
|
||||
applyOwnedByPipeline(record, r)
|
||||
|
||||
if err := txApp.Save(record); err != nil {
|
||||
return fmt.Errorf("failed to update audio record: %w", err)
|
||||
}
|
||||
|
||||
r.UpdatedAt = record.GetDateTime("updated").Time()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetByID отдаёт запись, только если её владелец — ownerID.
|
||||
//
|
||||
// Чужая запись, запись без владельца и несуществующая дают одну и ту же ошибку:
|
||||
// по разнице ответов иначе перебирается список заведённых записей, а
|
||||
// идентификатор записи и есть то, что разграничение прячет.
|
||||
//
|
||||
// Пустой ownerID отсекается **до** чтения и не совпадает ни с чем: иначе
|
||||
// вызывающий без учётной записи получил бы ровно множество записей без
|
||||
// владельца, то есть все записи бота.
|
||||
func (repo *AudioRecordRepository) GetByID(id, ownerID string) (*entity.AudioRecord, error) {
|
||||
if ownerID == "" {
|
||||
return nil, &contract.JobNotFoundError{Message: "record not found"}
|
||||
}
|
||||
|
||||
record, err := repo.find(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if record.GetString("owner") != ownerID {
|
||||
return nil, &contract.JobNotFoundError{Message: "record not found"}
|
||||
}
|
||||
|
||||
return recordToAudioRecord(record), nil
|
||||
}
|
||||
|
||||
// Get отдаёт запись без сужения владельцем: им пользуется конвейер, чья выборка
|
||||
// владельцем не сужается.
|
||||
func (repo *AudioRecordRepository) Get(id string) (*entity.AudioRecord, error) {
|
||||
record, err := repo.find(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return recordToAudioRecord(record), nil
|
||||
}
|
||||
|
||||
func (repo *AudioRecordRepository) find(id string) (*core.Record, error) {
|
||||
record, err := repo.app.FindRecordById(migrations.RecordsCollection, id)
|
||||
if err != nil {
|
||||
// «Такой записи нет» переводится в доменную ошибку **здесь**, у
|
||||
// источника, как велит конвенция об ошибках. Иначе три исхода, которые
|
||||
// разграничение обязано сделать неразличимыми, разъезжаются: чужая и
|
||||
// ничья записи дают доменную ошибку, а несуществующая — отказ базы,
|
||||
// неотличимый от настоящей аварии хранилища.
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, &contract.JobNotFoundError{Message: "record not found"}
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get audio record: %w", err)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// FindAndAcquire забирает пригодную к работе запись одним неделимым шагом:
|
||||
// выбор подходящей и пометка её захваченной идут вместе.
|
||||
//
|
||||
// Возвращается **идентификатор и признак этого захвата**, а не перечень колонок.
|
||||
// Колонки шаг читает обычным чтением: иначе всякая новая колонка записи попадала
|
||||
// бы под инвариант проекта о колонках очереди, а забытая приезжала бы нулевой, и
|
||||
// первое же сохранение писало бы этот ноль поверх сохранённого значения.
|
||||
//
|
||||
// Срок протухания захвата приезжает **с рубежом**, а не с воркером: воркер не
|
||||
// привязан к шагу и не знает заранее, что вытянет. Перечень рубежей и их сроков
|
||||
// приходит одним дескриптором — перечислять их порознь нельзя: рубеж, забытый в
|
||||
// отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту
|
||||
// проекта не пишется в журнал и не считается в метрику.
|
||||
//
|
||||
// Запрос идёт сырым, мимо записей коллекции: `app.DB()` направляет всё, кроме
|
||||
// выборок, в пул с единственным соединением, и захваты выстраиваются в очередь.
|
||||
// Хуки коллекции на нём не срабатывают, поэтому время изменения проставляет сам
|
||||
// запрос.
|
||||
//
|
||||
// Все времена кладутся и сравниваются тем же видом, каким хранилище пишет свои
|
||||
// `created`/`updated`: сравнение строк побайтово, и вид, разошедшийся хоть
|
||||
// разделителем, обратил бы условие срока в постоянную истину или постоянную
|
||||
// ложь — молча.
|
||||
func (repo *AudioRecordRepository) FindAndAcquire(stages []entity.Stage) (*contract.AcquiredRecord, error) {
|
||||
if len(stages) == 0 {
|
||||
return nil, &contract.JobNotFoundError{Message: "no working stages declared"}
|
||||
}
|
||||
|
||||
// Метка времени берётся единой точкой, а не `types.NowDateTime()`: обёртка
|
||||
// хранилища читает часы сама, и запрет линтера её не видит — новая метка в
|
||||
// этом запросе обошла бы единую точку молча.
|
||||
now, err := types.ParseDateTime(clock.Now())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse current time: %w", err)
|
||||
}
|
||||
|
||||
holder := uuid.NewString()
|
||||
params := dbx.Params{
|
||||
"holder": holder,
|
||||
"now": now.String(),
|
||||
}
|
||||
|
||||
// Срок протухания у каждого рубежа свой, поэтому он выбирается по рубежу
|
||||
// самой записи прямо в запросе: воркер, ещё не знающий, что вытянет,
|
||||
// подставить его не может.
|
||||
var expiry strings.Builder
|
||||
expiry.WriteString("CASE state")
|
||||
var states []string
|
||||
for i, stage := range stages {
|
||||
stateKey := fmt.Sprintf("state%d", i)
|
||||
expiryKey := fmt.Sprintf("expiry%d", i)
|
||||
|
||||
deadline, err := types.ParseDateTime(clock.Now().Add(stage.AcquireTimeout))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse acquire deadline: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&expiry, " WHEN {:%s} THEN {:%s}", stateKey, expiryKey)
|
||||
params[stateKey] = stage.Name
|
||||
params[expiryKey] = deadline.String()
|
||||
states = append(states, "{:"+stateKey+"}")
|
||||
}
|
||||
expiry.WriteString(" END")
|
||||
|
||||
table := "{{" + migrations.RecordsCollection + "}}"
|
||||
query := repo.app.DB().NewQuery(`
|
||||
UPDATE ` + table + `
|
||||
SET acquisition_id = {:holder},
|
||||
acquire_expires_at = ` + expiry.String() + `,
|
||||
attempts = attempts + 1,
|
||||
updated = {:now}
|
||||
WHERE id = (
|
||||
SELECT id FROM ` + table + `
|
||||
WHERE state IN (` + strings.Join(states, ", ") + `)
|
||||
AND (halted_at = '' OR halted_at IS NULL)
|
||||
AND (delay_time = '' OR delay_time IS NULL OR delay_time < {:now})
|
||||
AND (acquisition_id = '' OR acquisition_id IS NULL
|
||||
OR acquire_expires_at = '' OR acquire_expires_at IS NULL
|
||||
OR acquire_expires_at < {:now})
|
||||
ORDER BY created, id
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING id`)
|
||||
|
||||
query.Bind(params)
|
||||
|
||||
var row struct {
|
||||
Id string `db:"id"`
|
||||
}
|
||||
if err := query.One(&row); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, &contract.JobNotFoundError{Message: "no record is ready for work"}
|
||||
}
|
||||
return nil, fmt.Errorf("failed to acquire an audio record: %w", err)
|
||||
}
|
||||
|
||||
return &contract.AcquiredRecord{ID: row.Id, Holder: holder}, nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
)
|
||||
|
||||
// newTestStorage поднимает хранилище на пустом каталоге и накатывает схему —
|
||||
// тем же путём, каким это делает сервис при старте.
|
||||
func newTestStorage(t *testing.T) core.App {
|
||||
t.Helper()
|
||||
|
||||
app, err := New(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
if err := app.ResetBootstrapState(); err != nil {
|
||||
t.Logf("не удалось закрыть хранилище: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// Критерий приёмки 10. Содержимое записи закрыто во всех коллекциях, куда оно
|
||||
// переехало.
|
||||
//
|
||||
// Прежде содержимое лежало одной колонкой задачи, и закрывала его одна норма про
|
||||
// файл записи. Теперь оно живёт в шести коллекциях, и реализация, следующая
|
||||
// только прежней норме, завела бы поле вложения с умолчанием библиотеки: ссылка
|
||||
// на сырой ответ провайдера — а это полный текст речи — отдавала бы его любому,
|
||||
// кто её знает, без сессии.
|
||||
func TestRecordContentIsClosedEverywhere(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
// Правило просмотра остаётся незаданным, то есть «только владелец панели».
|
||||
// Содержимое отдаёт собственный адрес сервиса, а не поверхность хранилища;
|
||||
// непустое правило открыло бы перечисление коллекции впрок.
|
||||
for _, name := range []string{
|
||||
migrations.RecordsCollection,
|
||||
migrations.TextsCollection,
|
||||
migrations.StructuresCollection,
|
||||
migrations.RecognitionsCollection,
|
||||
migrations.RecordEventsCollection,
|
||||
migrations.TopicsCollection,
|
||||
} {
|
||||
collection, err := app.FindCollectionByNameOrId(name)
|
||||
require.NoError(t, err, "коллекция %s заведена шагом схемы", name)
|
||||
|
||||
assert.Nil(t, collection.ListRule, "перечисление %s закрыто", name)
|
||||
assert.Nil(t, collection.ViewRule, "чтение %s закрыто", name)
|
||||
assert.Nil(t, collection.CreateRule, "заведение записи в %s закрыто", name)
|
||||
assert.Nil(t, collection.UpdateRule, "правка %s закрыта", name)
|
||||
assert.Nil(t, collection.DeleteRule, "удаление из %s закрыто", name)
|
||||
}
|
||||
|
||||
// А поле вложения помечено защищённым: без пометки ссылка открывает
|
||||
// содержимое любому, кто её знает, и знание ссылки становится правом.
|
||||
recognitions, err := app.FindCollectionByNameOrId(migrations.RecognitionsCollection)
|
||||
require.NoError(t, err)
|
||||
|
||||
field := recognitions.Fields.GetByName("payload")
|
||||
require.NotNil(t, field, "поле сохранённого ответа заведено")
|
||||
|
||||
file, ok := field.(*core.FileField)
|
||||
require.True(t, ok, "сохранённый ответ лежит вложением, а не колонкой")
|
||||
assert.True(t, file.Protected, "поле вложения защищено")
|
||||
}
|
||||
|
||||
// Прежняя коллекция задач уходит вместе с моделью: данных под ней не было, а
|
||||
// пустая копия висела бы в панели вторым домом для понятия, которого больше нет.
|
||||
func TestFormerJobsCollectionIsGone(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
_, err := app.FindCollectionByNameOrId(migrations.JobsCollection)
|
||||
assert.Error(t, err, "прежней коллекции задач не осталось")
|
||||
}
|
||||
|
||||
// Пара «запись и вид» уникальна: повтор прерванного шага не заводит второго
|
||||
// комплекта строк, и вопрос «какой текст отдавать человеку» не становится
|
||||
// вопросом порядка записи.
|
||||
func TestAppendicesAreUniquePerRecord(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
indexes := map[string][]string{
|
||||
migrations.TextsCollection: {"idx_texts_record_kind"},
|
||||
migrations.StructuresCollection: {"idx_structures_record_version"},
|
||||
migrations.TopicsCollection: {"idx_topics_owner_name"},
|
||||
}
|
||||
|
||||
for name, expected := range indexes {
|
||||
collection, err := app.FindCollectionByNameOrId(name)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, index := range expected {
|
||||
var found bool
|
||||
for _, declared := range collection.Indexes {
|
||||
if strings.Contains(declared, index) && strings.Contains(declared, "UNIQUE") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
assert.Truef(t, found, "у %s есть уникальный индекс %s", name, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
type TextRepository struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
func NewTextRepository(app core.App) *TextRepository {
|
||||
return &TextRepository{app: app}
|
||||
}
|
||||
|
||||
// Put кладёт текст записи, заменяя прежний того же вида.
|
||||
//
|
||||
// Замена, а не вставка: пара «запись и вид» уникальна, и повтор прерванного шага
|
||||
// иначе завёл бы второй комплект строк — тогда вопрос «какой текст отдавать
|
||||
// человеку» стал бы вопросом порядка записи, а не состояния.
|
||||
func (repo *TextRepository) Put(recordID, kind, contents string) (*entity.Text, error) {
|
||||
collection, err := findCollection(repo.app, migrations.TextsCollection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
record, err := repo.app.FindFirstRecordByFilter(
|
||||
migrations.TextsCollection,
|
||||
"record = {:record} && kind = {:kind}",
|
||||
dbx.Params{"record": recordID, "kind": kind},
|
||||
)
|
||||
switch {
|
||||
case err == nil:
|
||||
// Строка есть — заменяем содержимое.
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
record = core.NewRecord(collection)
|
||||
record.Set("record", recordID)
|
||||
record.Set("kind", kind)
|
||||
default:
|
||||
// Отказ хранилища «строкой нет» не является, и подменять его вставкой
|
||||
// нельзя: она упрётся в уникальный индекс, и наверх уедет жалоба на
|
||||
// запись вместо правды о недоступной базе.
|
||||
return nil, fmt.Errorf("failed to look up text of kind %s for record %s: %w", kind, recordID, err)
|
||||
}
|
||||
record.Set("contents", contents)
|
||||
|
||||
if err := repo.app.Save(record); err != nil {
|
||||
// Текст расшифровки наружу не выходит даже отказом: цепочка `%w` от
|
||||
// хранилища несёт значение поля.
|
||||
return nil, fmt.Errorf("failed to store text of kind %s for record %s", kind, recordID)
|
||||
}
|
||||
|
||||
return textFromRecord(record), nil
|
||||
}
|
||||
|
||||
func (repo *TextRepository) GetByID(id string) (*entity.Text, error) {
|
||||
record, err := repo.app.FindRecordById(migrations.TextsCollection, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get text %s: %w", id, err)
|
||||
}
|
||||
return textFromRecord(record), nil
|
||||
}
|
||||
|
||||
func textFromRecord(record *core.Record) *entity.Text {
|
||||
return &entity.Text{
|
||||
Id: record.Id,
|
||||
RecordID: record.GetString("record"),
|
||||
Kind: record.GetString("kind"),
|
||||
Contents: record.GetString("contents"),
|
||||
}
|
||||
}
|
||||
|
||||
type StructureRepository struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
func NewStructureRepository(app core.App) *StructureRepository {
|
||||
return &StructureRepository{app: app}
|
||||
}
|
||||
|
||||
// Put кладёт структуру реплик, заменяя прежнюю той же версии разбора. Довод тот
|
||||
// же, что и у текста: повтор шага не должен заводить второй строки.
|
||||
func (repo *StructureRepository) Put(recordID string, version int, replicas []entity.Replica) (*entity.Structure, error) {
|
||||
collection, err := findCollection(repo.app, migrations.StructuresCollection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
contents, err := json.Marshal(replicas)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode structure of record %s", recordID)
|
||||
}
|
||||
|
||||
record, err := repo.app.FindFirstRecordByFilter(
|
||||
migrations.StructuresCollection,
|
||||
"record = {:record} && version = {:version}",
|
||||
dbx.Params{"record": recordID, "version": version},
|
||||
)
|
||||
switch {
|
||||
case err == nil:
|
||||
// Строка есть — заменяем содержимое.
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
record = core.NewRecord(collection)
|
||||
record.Set("record", recordID)
|
||||
record.Set("version", version)
|
||||
default:
|
||||
return nil, fmt.Errorf("failed to look up structure of record %s: %w", recordID, err)
|
||||
}
|
||||
record.Set("contents", string(contents))
|
||||
|
||||
if err := repo.app.Save(record); err != nil {
|
||||
return nil, fmt.Errorf("failed to store structure of record %s", recordID)
|
||||
}
|
||||
|
||||
return &entity.Structure{
|
||||
Id: record.Id,
|
||||
RecordID: recordID,
|
||||
Version: version,
|
||||
Replicas: replicas,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (repo *StructureRepository) GetByID(id string) (*entity.Structure, error) {
|
||||
record, err := repo.app.FindRecordById(migrations.StructuresCollection, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get structure %s: %w", id, err)
|
||||
}
|
||||
|
||||
var replicas []entity.Replica
|
||||
raw := record.GetString("contents")
|
||||
if raw != "" {
|
||||
if err := json.Unmarshal([]byte(raw), &replicas); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode structure %s", id)
|
||||
}
|
||||
}
|
||||
|
||||
return &entity.Structure{
|
||||
Id: record.Id,
|
||||
RecordID: record.GetString("record"),
|
||||
Version: record.GetInt("version"),
|
||||
Replicas: replicas,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
)
|
||||
|
||||
type TranscriptJobRepository struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
func NewTranscriptJobRepository(app core.App) *TranscriptJobRepository {
|
||||
return &TranscriptJobRepository{app: app}
|
||||
}
|
||||
|
||||
func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
|
||||
collection, err := findCollection(repo.app, migrations.JobsCollection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
if job.Id != "" {
|
||||
record.Id = job.Id
|
||||
}
|
||||
applyToRecord(record, job)
|
||||
|
||||
if err := repo.app.Save(record); err != nil {
|
||||
return fmt.Errorf("failed to insert transcribe job: %w", err)
|
||||
}
|
||||
|
||||
job.Id = record.Id
|
||||
job.CreatedAt = record.GetDateTime("created").Time()
|
||||
job.UpdatedAt = record.GetDateTime("updated").Time()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save сохраняет задачу, захват которой держит holder. Проверка и запись идут
|
||||
// одной транзакцией: шаг, потерявший задачу за время работы, получает
|
||||
// LostAcquisitionError и результата не пишет.
|
||||
func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob, holder string) error {
|
||||
err := repo.app.RunInTransaction(func(txApp core.App) error {
|
||||
record, err := txApp.FindRecordById(migrations.JobsCollection, job.Id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find transcribe job: %w", err)
|
||||
}
|
||||
|
||||
if holder != "" && record.GetString("acquisition_id") != holder {
|
||||
return &contract.LostAcquisitionError{JobID: job.Id}
|
||||
}
|
||||
|
||||
// Кладём только то, чем распоряжается конвейер: правку владельца в
|
||||
// панели снимок шага стирать не должен.
|
||||
applyOwnedByPipeline(record, job)
|
||||
|
||||
if err := txApp.Save(record); err != nil {
|
||||
return fmt.Errorf("failed to update transcribe job: %w", err)
|
||||
}
|
||||
|
||||
job.UpdatedAt = record.GetDateTime("updated").Time()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID отдаёт задачу, только если её владелец — ownerID.
|
||||
//
|
||||
// Чужая задача, задача без владельца и несуществующая дают одну и ту же ошибку:
|
||||
// по разнице ответов иначе перебирается список заведённых задач, а
|
||||
// идентификатор задачи и есть то, что разграничение прячет.
|
||||
//
|
||||
// Пустой ownerID отсекается **до** чтения и не совпадает ни с чем: иначе
|
||||
// вызывающий без учётной записи получил бы ровно множество задач без владельца,
|
||||
// то есть все записи бота.
|
||||
//
|
||||
// Владелец сверяется тем же чтением, каким берётся состояние, а не отдельным
|
||||
// запросом: между двумя чтениями задача успевает измениться, и ответ перестаёт
|
||||
// быть функцией от того, что с ней произошло.
|
||||
func (repo *TranscriptJobRepository) GetByID(id, ownerID string) (*entity.TranscribeJob, error) {
|
||||
if ownerID == "" {
|
||||
return nil, &contract.JobNotFoundError{Message: "job not found"}
|
||||
}
|
||||
|
||||
record, err := repo.app.FindRecordById(migrations.JobsCollection, id)
|
||||
if err != nil {
|
||||
// «Такой записи нет» переводится в доменную ошибку **здесь**, у
|
||||
// источника, как велит конвенция об ошибках. Иначе три исхода, которые
|
||||
// разграничение обязано сделать неразличимыми, разъезжаются: чужая и
|
||||
// ничья задачи дают доменную ошибку, а несуществующая — отказ базы,
|
||||
// неотличимый от настоящей аварии хранилища. Держалась бы эта
|
||||
// неразличимость только тем, что транспорт кладёт в `404` любую ошибку,
|
||||
// — то есть ровно тем расхождением, которое конвенция велит закрыть.
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, &contract.JobNotFoundError{Message: "job not found"}
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get transcribe job: %w", err)
|
||||
}
|
||||
|
||||
if record.GetString("owner") != ownerID {
|
||||
return nil, &contract.JobNotFoundError{Message: "job not found"}
|
||||
}
|
||||
|
||||
return recordToJob(record), nil
|
||||
}
|
||||
|
||||
// Колонки, которые читает захват. Список нужен запросу дословно: `RETURNING *`
|
||||
// отдал бы и порядок, зависящий от схемы.
|
||||
const acquireColumns = `id, state, owner, source, file, error_text, acquisition_id, ` +
|
||||
`acquire_time, delay_time, attempts, recognition_op_id, transcription_text, ` +
|
||||
`tg_chat_id, tg_reply_message_id, created, updated`
|
||||
|
||||
// FindAndAcquire забирает задачу одним неделимым шагом: выбор подходящей и
|
||||
// пометка её захваченной идут вместе, и захваченная возвращается тем же
|
||||
// запросом. Двум вызывающим, пришедшим за одним состоянием, запись достаётся
|
||||
// одному — на этом стоит инвариант «Принятая запись не теряется молча».
|
||||
//
|
||||
// Запрос идёт сырым, мимо записей коллекции: `app.DB()` направляет всё, кроме
|
||||
// выборок, в пул с единственным соединением, и захваты выстраиваются в очередь.
|
||||
// Хуки коллекции на нём не срабатывают, поэтому время изменения проставляет сам
|
||||
// запрос.
|
||||
//
|
||||
// Все времена кладутся и сравниваются тем же видом, каким хранилище пишет свои
|
||||
// `created`/`updated`: сравнение строк побайтово, и вид, разошедшийся хоть
|
||||
// разделителем, обратил бы условие срока в постоянную истину или постоянную
|
||||
// ложь — молча.
|
||||
func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error) {
|
||||
// Метка времени берётся единой точкой, а не `types.NowDateTime()`: обёртка
|
||||
// хранилища читает часы сама, и запрет линтера её не видит — новая метка в
|
||||
// этом запросе обошла бы единую точку молча.
|
||||
now, err := types.ParseDateTime(clock.Now())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse current time: %w", err)
|
||||
}
|
||||
|
||||
query := repo.app.DB().NewQuery(`
|
||||
UPDATE {{` + migrations.JobsCollection + `}}
|
||||
SET acquisition_id = {:acquisition_id},
|
||||
acquire_time = {:now},
|
||||
attempts = attempts + 1,
|
||||
updated = {:now}
|
||||
WHERE id = (
|
||||
SELECT id FROM {{` + migrations.JobsCollection + `}}
|
||||
WHERE state = {:state}
|
||||
AND (delay_time = '' OR delay_time IS NULL OR delay_time < {:now})
|
||||
AND (acquisition_id = '' OR acquisition_id IS NULL OR acquire_time < {:rotting})
|
||||
ORDER BY created, id
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING ` + acquireColumns)
|
||||
|
||||
rotting, err := types.ParseDateTime(rottingTime)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse rotting time: %w", err)
|
||||
}
|
||||
|
||||
query.Bind(dbx.Params{
|
||||
"acquisition_id": acquisitionId,
|
||||
"now": now.String(),
|
||||
"state": state,
|
||||
"rotting": rotting.String(),
|
||||
})
|
||||
|
||||
var row acquiredRow
|
||||
if err := query.One(&row); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, &contract.JobNotFoundError{State: state, Message: "appropriate job not found"}
|
||||
}
|
||||
return nil, fmt.Errorf("failed to acquire job with state %s: %w", state, err)
|
||||
}
|
||||
|
||||
return row.toJob(), nil
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
)
|
||||
|
||||
// newTestApp поднимает хранилище на пустом каталоге и накатывает схему — тем же
|
||||
// путём, каким это делает сервис при старте.
|
||||
func newTestApp(t *testing.T) core.App {
|
||||
t.Helper()
|
||||
|
||||
app, err := New(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
if err := app.ResetBootstrapState(); err != nil {
|
||||
t.Logf("не удалось закрыть хранилище: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// newFile заводит запись о файле: ссылка на неё у задачи обязательна схемой.
|
||||
func newFile(t *testing.T, app core.App) *entity.File {
|
||||
t.Helper()
|
||||
|
||||
repo := NewFileRepository(app)
|
||||
work, err := repo.Stage(".mp3", strings.NewReader("запись"))
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, work.Close()) }()
|
||||
|
||||
file, err := repo.CreateLocal("sample.mp3", work, "")
|
||||
require.NoError(t, err)
|
||||
return file
|
||||
}
|
||||
|
||||
func newJob(t *testing.T, repo *TranscriptJobRepository, state string) *entity.TranscribeJob {
|
||||
t.Helper()
|
||||
|
||||
file := newFile(t, repo.app)
|
||||
job := &entity.TranscribeJob{State: state, Source: entity.SourceApi, FileID: &file.Id}
|
||||
require.NoError(t, repo.Create(job))
|
||||
return job
|
||||
}
|
||||
|
||||
// Захват неделим: выбор подходящей задачи и пометка её захваченной идут вместе.
|
||||
// Двум вызывающим, пришедшим за одним состоянием разом, запись достаётся
|
||||
// одному — на этом стоит инвариант «Принятая запись не теряется молча».
|
||||
func TestFindAndAcquire_OnlyOneOfThreeGetsTheJob(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
|
||||
job := newJob(t, repo, entity.StateCreated)
|
||||
|
||||
const racers = 3
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
got []*entity.TranscribeJob
|
||||
notFound int
|
||||
)
|
||||
|
||||
start := make(chan struct{})
|
||||
for i := 0; i < racers; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
|
||||
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
var missing *contract.JobNotFoundError
|
||||
if assert.ErrorAs(t, err, &missing) {
|
||||
notFound++
|
||||
}
|
||||
return
|
||||
}
|
||||
got = append(got, acquired)
|
||||
}(i)
|
||||
}
|
||||
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
require.Len(t, got, 1, "запись получает ровно один из трёх захватов")
|
||||
assert.Equal(t, job.Id, got[0].Id)
|
||||
assert.Equal(t, racers-1, notFound, "остальные получают признак «работы нет»")
|
||||
}
|
||||
|
||||
// Захваченная задача второй раз не выдаётся, пока срок захвата не истёк.
|
||||
func TestFindAndAcquire_AcquiredJobIsNotHandedOutAgain(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
|
||||
newJob(t, repo, entity.StateCreated)
|
||||
|
||||
first, err := repo.FindAndAcquire(entity.StateCreated, "first", time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, first)
|
||||
|
||||
_, err = repo.FindAndAcquire(entity.StateCreated, "second", time.Now().Add(-time.Hour))
|
||||
|
||||
var missing *contract.JobNotFoundError
|
||||
assert.ErrorAs(t, err, &missing, "захваченная задача второму не выдаётся")
|
||||
}
|
||||
|
||||
// Захват протухает, и задача достаётся снова. Время захвата кладётся **не**
|
||||
// нашим кодом, а тем же путём, что и `created`: проверка, кладущая его своим
|
||||
// форматом, была бы зелена и тогда, когда сравнение вида сломано.
|
||||
func TestFindAndAcquire_RottenAcquisitionIsHandedOutAgain(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
|
||||
job := newJob(t, repo, entity.StateCreated)
|
||||
|
||||
_, err := repo.FindAndAcquire(entity.StateCreated, "first", time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Задним числом — записью коллекции, то есть тем же слоем, который пишет
|
||||
// собственные времена хранилища.
|
||||
record, err := app.FindRecordById(migrations.JobsCollection, job.Id)
|
||||
require.NoError(t, err)
|
||||
record.Set("acquire_time", types.NowDateTime().Add(-2*time.Hour))
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
again, err := repo.FindAndAcquire(entity.StateCreated, "second", time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err, "протухший захват не мешает выдать задачу следующему")
|
||||
assert.Equal(t, job.Id, again.Id)
|
||||
}
|
||||
|
||||
// Пауза держит задачу от выдачи, пока не кончится.
|
||||
func TestFindAndAcquire_DelayedJobIsNotHandedOut(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
|
||||
job := newJob(t, repo, entity.StateCreated)
|
||||
|
||||
delay := time.Now().Add(time.Hour)
|
||||
job.DelayTime = &delay
|
||||
require.NoError(t, repo.Save(job, ""))
|
||||
|
||||
_, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
|
||||
|
||||
var missing *contract.JobNotFoundError
|
||||
assert.ErrorAs(t, err, &missing, "задача не выдаётся, пока пауза не кончилась")
|
||||
}
|
||||
|
||||
// Число попыток растёт при каждом захвате: только так попытка засчитывается и
|
||||
// задаче, брошенной вместе с процессом.
|
||||
func TestFindAndAcquire_AttemptsGrowOnEveryAcquisition(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
|
||||
newJob(t, repo, entity.StateCreated)
|
||||
|
||||
for expected := 1; expected <= 3; expected++ {
|
||||
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expected, acquired.Attempts)
|
||||
}
|
||||
}
|
||||
|
||||
// Захват отдаёт задачу целиком, а не только её ключ: сырой запрос идёт мимо
|
||||
// записей коллекции, и расхождение перечня колонок иначе проявилось бы как
|
||||
// потерянное поле.
|
||||
func TestFindAndAcquire_ReturnsWholeJob(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
|
||||
chatId := int64(4242)
|
||||
replyId := 17
|
||||
opId := "operation-id"
|
||||
text := "расшифровка"
|
||||
|
||||
file := newFile(t, app)
|
||||
|
||||
job := &entity.TranscribeJob{
|
||||
State: entity.StateTranscribe,
|
||||
Source: entity.SourceTelegram,
|
||||
FileID: &file.Id,
|
||||
TgChatId: &chatId,
|
||||
TgReplyMessageId: &replyId,
|
||||
RecognitionOpID: &opId,
|
||||
TranscriptionText: &text,
|
||||
}
|
||||
require.NoError(t, repo.Create(job))
|
||||
|
||||
acquired, err := repo.FindAndAcquire(entity.StateTranscribe, "holder", time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, job.Id, acquired.Id)
|
||||
assert.Equal(t, entity.StateTranscribe, acquired.State)
|
||||
assert.Equal(t, entity.SourceTelegram, acquired.Source)
|
||||
require.NotNil(t, acquired.TgChatId)
|
||||
assert.Equal(t, chatId, *acquired.TgChatId)
|
||||
require.NotNil(t, acquired.TgReplyMessageId)
|
||||
assert.Equal(t, replyId, *acquired.TgReplyMessageId)
|
||||
require.NotNil(t, acquired.RecognitionOpID)
|
||||
assert.Equal(t, opId, *acquired.RecognitionOpID)
|
||||
require.NotNil(t, acquired.TranscriptionText)
|
||||
assert.Equal(t, text, *acquired.TranscriptionText)
|
||||
assert.False(t, acquired.CreatedAt.IsZero(), "время заведения доехало")
|
||||
}
|
||||
|
||||
// Шаг, потерявший захват за время работы, результата не пишет: иначе два
|
||||
// воркера пишут в одну задачу по очереди, а отправитель получает два ответа.
|
||||
func TestSave_RefusesWriteFromLostAcquisition(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
|
||||
newJob(t, repo, entity.StateCreated)
|
||||
|
||||
mine, err := repo.FindAndAcquire(entity.StateCreated, "mine", time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Задача досталась другому, пока шаг работал.
|
||||
record, err := app.FindRecordById(migrations.JobsCollection, mine.Id)
|
||||
require.NoError(t, err)
|
||||
record.Set("acquisition_id", "someone-else")
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
mine.MoveToState(entity.StateConverted)
|
||||
err = repo.Save(mine, "mine")
|
||||
|
||||
var lost *contract.LostAcquisitionError
|
||||
require.ErrorAs(t, err, &lost)
|
||||
|
||||
// И состояние не поехало.
|
||||
after, err := readJobByID(t, app, mine.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateCreated, after.State)
|
||||
}
|
||||
|
||||
// Пустой держатель значит «задача не захватывалась» — так её сохраняет приём.
|
||||
func TestSave_WithoutHolderWritesAnyway(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
|
||||
job := newJob(t, repo, entity.StateCreated)
|
||||
job.MoveToState(entity.StateConverted)
|
||||
|
||||
require.NoError(t, repo.Save(job, ""))
|
||||
|
||||
after, err := readJobByID(t, app, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateConverted, after.State)
|
||||
}
|
||||
|
||||
// Правка состояния **запросом** — то есть из панели — чистит служебные поля
|
||||
// прошлого состояния: те же, что чистит переход из кода. Иначе владелец,
|
||||
// вернувший мёртвую задачу в работу, получил бы задачу, которая не выдаётся
|
||||
// захвату и умирает от первого же отказа, и не узнал бы об этом.
|
||||
func TestPanelRules_StateChangeByRequestClearsAcquisition(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
BindPanelRules(app)
|
||||
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
job := newJob(t, repo, entity.StateCreated)
|
||||
|
||||
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, acquired.AcquisitionID)
|
||||
|
||||
record, err := app.FindRecordById(migrations.JobsCollection, job.Id)
|
||||
require.NoError(t, err)
|
||||
record.Set("attempts", 5)
|
||||
record.Set("state", entity.StateDead)
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
// Владелец возвращает задачу в работу правкой состояния в панели — то есть
|
||||
// запросом к записи, а не сохранением из кода.
|
||||
patchRecord(t, app, job.Id, `{"state":"`+entity.StateCreated+`"}`)
|
||||
|
||||
after, err := readJobByID(t, app, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, after.AcquisitionID, "признак захвата снят")
|
||||
assert.Nil(t, after.AcquireTime, "время захвата снято")
|
||||
assert.Nil(t, after.DelayTime, "пауза снята")
|
||||
assert.Equal(t, 0, after.Attempts, "число попыток обнулено")
|
||||
|
||||
// И ближайший захват задачу выдаёт.
|
||||
again, err := repo.FindAndAcquire(entity.StateCreated, "next", time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, job.Id, again.Id)
|
||||
}
|
||||
|
||||
// Обратная сторона того же правила, и она дороже: правила панели MUST не
|
||||
// трогать записи, которые правит сам конвейер. Модельный хук их не различал, и
|
||||
// пауза, поставленная шагом вместе со сменой состояния, стиралась тем же
|
||||
// сохранением, а число попыток мёртвой задачи приходило владельцу нулём.
|
||||
func TestPanelRules_DoNotTouchPipelineWrites(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
BindPanelRules(app)
|
||||
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
job := newJob(t, repo, entity.StateConverted)
|
||||
|
||||
acquired, err := repo.FindAndAcquire(entity.StateConverted, "holder", time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Шаг ставит задержку опроса вместе со сменой состояния.
|
||||
delay := time.Now().Add(10 * time.Second)
|
||||
acquired.MoveToStateAndDelay(entity.StateTranscribe, &delay)
|
||||
require.NoError(t, repo.Save(acquired, "holder"))
|
||||
|
||||
after, err := readJobByID(t, app, job.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, after.DelayTime, "задержка, поставленная шагом, пережила сохранение")
|
||||
|
||||
// Переход в «мертва» хранит число попыток намеренно: по нему владелец видит,
|
||||
// сколько раз мы пробовали.
|
||||
after.Attempts = 6
|
||||
after.Die("attempts exhausted: 6")
|
||||
require.NoError(t, repo.Save(after, ""))
|
||||
|
||||
dead, err := readJobByID(t, app, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateDead, dead.State)
|
||||
assert.Equal(t, 6, dead.Attempts, "число попыток мёртвой задачи сохранено")
|
||||
}
|
||||
|
||||
// patchRecord правит запись тем же путём, каким её правит панель: запросом к
|
||||
// API от имени владельца.
|
||||
func patchRecord(t *testing.T, app core.App, recordID, body string) {
|
||||
t.Helper()
|
||||
|
||||
superusers, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
|
||||
require.NoError(t, err)
|
||||
|
||||
owner := core.NewRecord(superusers)
|
||||
owner.Set("email", "owner@example.com")
|
||||
owner.Set("password", "ownerpassword123")
|
||||
require.NoError(t, app.Save(owner))
|
||||
|
||||
token, err := owner.NewStaticAuthToken(time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
router, err := apis.NewRouter(app)
|
||||
require.NoError(t, err)
|
||||
mux, err := router.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/collections/"+migrations.JobsCollection+"/records/"+recordID,
|
||||
strings.NewReader(body),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", token)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code, "правка записи владельцем: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// Правка владельца в панели переживает сохранение шага. Шаг держит задачу
|
||||
// снимком с момента захвата и до своего сохранения — до восьми часов, — и
|
||||
// безусловная запись снимка стёрла бы правку молча: ни строки в журнале, ни
|
||||
// отказа в панели.
|
||||
func TestSave_KeepsOwnerEditMadeWhileStepHeldTheJob(t *testing.T) {
|
||||
app := newTestApp(t)
|
||||
BindPanelRules(app)
|
||||
|
||||
repo := NewTranscriptJobRepository(app)
|
||||
|
||||
file := newFile(t, app)
|
||||
chatId := int64(111)
|
||||
job := &entity.TranscribeJob{
|
||||
State: entity.StateCreated,
|
||||
Source: entity.SourceTelegram,
|
||||
FileID: &file.Id,
|
||||
TgChatId: &chatId,
|
||||
}
|
||||
require.NoError(t, repo.Create(job))
|
||||
|
||||
// Шаг захватил задачу и работает.
|
||||
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Владелец правит в панели поле, которого конвейер не касается.
|
||||
patchRecord(t, app, job.Id, `{"tg_chat_id":999999}`)
|
||||
|
||||
// Шаг доработал и сохраняет свой снимок.
|
||||
acquired.MoveToState(entity.StateConverted)
|
||||
require.NoError(t, repo.Save(acquired, "holder"))
|
||||
|
||||
after, err := readJobByID(t, app, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateConverted, after.State, "шаг свой результат записал")
|
||||
require.NotNil(t, after.TgChatId)
|
||||
assert.Equal(t, int64(999999), *after.TgChatId, "правка владельца пережила сохранение шага")
|
||||
}
|
||||
|
||||
// readJobByID читает задачу мимо сужения владельцем: проверки хранилища смотрят
|
||||
// задачи без владельца, а читающий метод их не отдаёт никому. Отображение при
|
||||
// этом то же самое — сверяется именно оно.
|
||||
func readJobByID(t *testing.T, app core.App, id string) (*entity.TranscribeJob, error) {
|
||||
t.Helper()
|
||||
|
||||
record, err := app.FindRecordById(migrations.JobsCollection, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return recordToJob(record), nil
|
||||
}
|
||||
Reference in New Issue
Block a user