хранилище, файлы записей и очередь переведены на встроенную PocketBase
- записи, метаданные и файлы съехались под один каталог данных; появилась панель владельца, а gin, goqu, goose и требование CGO ушли - захват задачи стал одним запросом с RETURNING; заведены число попыток, состояние dead и нарастающая пауза вместо признака is_error - имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с идентификатором записи оно собирало бы ссылку на скачивание
This commit is contained in:
@@ -24,8 +24,8 @@ type stubJobRepo struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *stubJobRepo) Create(*entity.TranscribeJob) error { return nil }
|
||||
func (r *stubJobRepo) Save(*entity.TranscribeJob) error { return nil }
|
||||
func (r *stubJobRepo) Create(*entity.TranscribeJob) error { return nil }
|
||||
func (r *stubJobRepo) Save(*entity.TranscribeJob, string) error { return nil }
|
||||
|
||||
func (r *stubJobRepo) GetByID(string) (*entity.TranscribeJob, error) {
|
||||
return nil, errors.New("не зовётся этими проверками")
|
||||
@@ -37,7 +37,7 @@ func (r *stubJobRepo) FindAndAcquire(string, string, time.Time) (*entity.Transcr
|
||||
|
||||
func serviceWithRepo(repo contract.TranscriptJobRepository) *TranscribeService {
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return NewTranscribeService(repo, nil, nil, nil, nil, nil, "", logger)
|
||||
return NewTranscribeService(repo, nil, nil, nil, nil, nil, logger)
|
||||
}
|
||||
|
||||
// Репозиторий вправе добавить своему отказу пояснение — соседние ветки того же
|
||||
@@ -50,7 +50,7 @@ func TestFindJobTranslatesWrappedNotFoundToNoop(t *testing.T) {
|
||||
&contract.JobNotFoundError{State: "created", Message: "appropriate job not found"}),
|
||||
})
|
||||
|
||||
_, err := svc.findJob("created", time.Minute)
|
||||
_, _, err := svc.findJob("created", time.Minute)
|
||||
|
||||
var noop *contract.NoopJobError
|
||||
if !errors.As(err, &noop) {
|
||||
@@ -66,7 +66,7 @@ func TestFindJobTranslatesWrappedNotFoundToNoop(t *testing.T) {
|
||||
func TestFindJobKeepsRealFailure(t *testing.T) {
|
||||
svc := serviceWithRepo(&stubJobRepo{err: errors.New("database is gone")})
|
||||
|
||||
_, err := svc.findJob("created", time.Minute)
|
||||
_, _, err := svc.findJob("created", time.Minute)
|
||||
|
||||
var noop *contract.NoopJobError
|
||||
if errors.As(err, &noop) {
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Проверки конвейера идут против настоящего хранилища: захват, число попыток и
|
||||
// переход в «мертва» держатся на запросе, и подставной репозиторий проверял бы
|
||||
// собственную заглушку, а не то, что делает база.
|
||||
|
||||
// failingConverter отказывает на каждой попытке.
|
||||
type failingConverter struct{}
|
||||
|
||||
func (c *failingConverter) Convert(string, string) error {
|
||||
return errors.New("конвертация не удалась")
|
||||
}
|
||||
|
||||
type okMetaViewer struct{}
|
||||
|
||||
func (m *okMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
|
||||
return &contract.AudioInfo{Seconds: 1}, nil
|
||||
}
|
||||
|
||||
type failingMetaViewer struct{}
|
||||
|
||||
func (m *failingMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
|
||||
return nil, errors.New("запись не читается")
|
||||
}
|
||||
|
||||
// recordingSender запоминает, что и куда отправлено.
|
||||
type recordingSender struct {
|
||||
messages []string
|
||||
}
|
||||
|
||||
func (s *recordingSender) Send(text string, chatId int64, replyMsgId *int) error {
|
||||
s.messages = append(s.messages, text)
|
||||
return nil
|
||||
}
|
||||
|
||||
type pipelineEnv struct {
|
||||
app core.App
|
||||
service *TranscribeService
|
||||
jobRepo *pbrepo.TranscriptJobRepository
|
||||
fileRepo *pbrepo.FileRepository
|
||||
sender *recordingSender
|
||||
}
|
||||
|
||||
func newPipelineEnv(t *testing.T, metaviewer contract.AudioMetaViewer, converter contract.AudioFileConverter) *pipelineEnv {
|
||||
t.Helper()
|
||||
|
||||
app, err := pbrepo.New(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
if err := app.ResetBootstrapState(); err != nil {
|
||||
t.Logf("не удалось закрыть хранилище: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Правила панели вешаются и здесь: конфигурация под проверкой обязана
|
||||
// совпадать с боевой, иначе утверждения говорят про прод то, чего в проде
|
||||
// нет.
|
||||
pbrepo.BindPanelRules(app)
|
||||
|
||||
jobRepo := pbrepo.NewTranscriptJobRepository(app)
|
||||
fileRepo := pbrepo.NewFileRepository(app)
|
||||
sender := &recordingSender{}
|
||||
|
||||
svc := NewTranscribeService(
|
||||
jobRepo,
|
||||
fileRepo,
|
||||
metaviewer,
|
||||
converter,
|
||||
&recognizer.MemoryAudioRecognizer{},
|
||||
sender,
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
)
|
||||
|
||||
return &pipelineEnv{app: app, service: svc, jobRepo: jobRepo, fileRepo: fileRepo, sender: sender}
|
||||
}
|
||||
|
||||
// newTelegramJob заводит задачу с записью — так, как её завёл бы приём.
|
||||
func newTelegramJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob {
|
||||
t.Helper()
|
||||
|
||||
chatId := int64(100)
|
||||
job, err := env.service.CreateJobFromTelegram(strings.NewReader("запись"), "voice.ogg", chatId, 1)
|
||||
require.NoError(t, err)
|
||||
return job
|
||||
}
|
||||
|
||||
// clearDelay снимает паузу, чтобы следующий прогон взял задачу сразу: проверка
|
||||
// судит счётчик попыток, а не то, умеет ли она ждать.
|
||||
func clearDelay(t *testing.T, env *pipelineEnv, jobID string) {
|
||||
t.Helper()
|
||||
|
||||
record, err := env.app.FindRecordById(pbrepo.JobsCollection, jobID)
|
||||
require.NoError(t, err)
|
||||
record.Set("delay_time", "")
|
||||
require.NoError(t, env.app.Save(record))
|
||||
}
|
||||
|
||||
// rotAcquisition отодвигает время захвата так, чтобы он протух: так это
|
||||
// выглядит, когда шаг оборвался вместе с процессом.
|
||||
func rotAcquisition(t *testing.T, env *pipelineEnv, jobID string) {
|
||||
t.Helper()
|
||||
|
||||
record, err := env.app.FindRecordById(pbrepo.JobsCollection, jobID)
|
||||
require.NoError(t, err)
|
||||
record.Set("acquire_time", types.NowDateTime().Add(-24*time.Hour))
|
||||
require.NoError(t, env.app.Save(record))
|
||||
}
|
||||
|
||||
// Задача, падающая на каждой попытке, уходит в «мертва»: из выборки исчезает,
|
||||
// видна отбором по состоянию, а отправитель узнаёт о неудаче. Инвариант
|
||||
// «Принятая запись не теряется молча» допускает два исхода, и молчаливая смерть
|
||||
// не подходит ни под один.
|
||||
func TestJobDiesAfterAttemptLimit(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
job := newTelegramJob(t, env)
|
||||
|
||||
// Отказ конвертации переводит задачу в `failed` сразу, поэтому предел
|
||||
// попыток проверяем на шаге, который отказывает *не* приговором: подменяем
|
||||
// его отказом источника метаданных внутри самого шага конвертации нельзя, и
|
||||
// вместо этого гоняем захват без выполнения шага — так же, как это выглядит
|
||||
// при гибели процесса.
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
_, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
rotAcquisition(t, env, job.Id)
|
||||
|
||||
// Следующий захват видит перебор и хоронит задачу.
|
||||
err := env.service.FindAndRunConversionJob()
|
||||
|
||||
var noop *contract.NoopJobError
|
||||
require.ErrorAs(t, err, &noop, "мёртвая задача шагу не отдаётся")
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateDead, after.State, "задача видна отбором по состоянию")
|
||||
assert.Greater(t, after.Attempts, maxAttempts, "число попыток сохранено")
|
||||
|
||||
require.Len(t, env.sender.messages, 1, "отправитель узнал о неудаче")
|
||||
assert.Contains(t, env.sender.messages[0], "попытки исчерпаны")
|
||||
|
||||
// И из выборки она исчезла.
|
||||
_, err = env.jobRepo.FindAndAcquire(entity.StateCreated, "next", time.Now().Add(time.Hour))
|
||||
var missing *contract.JobNotFoundError
|
||||
assert.ErrorAs(t, err, &missing)
|
||||
}
|
||||
|
||||
// Мёртвая задача возвращается в работу правкой состояния.
|
||||
func TestDeadJobReturnsAfterStateEdit(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
job := newTelegramJob(t, env)
|
||||
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
_, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.Error(t, env.service.FindAndRunConversionJob())
|
||||
|
||||
record, err := env.app.FindRecordById(pbrepo.JobsCollection, job.Id)
|
||||
require.NoError(t, err)
|
||||
record.Set("state", entity.StateCreated)
|
||||
require.NoError(t, env.app.Save(record))
|
||||
|
||||
again, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "next", time.Now().Add(time.Hour))
|
||||
require.NoError(t, err, "снятое состояние возвращает задачу в работу")
|
||||
assert.Equal(t, job.Id, again.Id)
|
||||
}
|
||||
|
||||
// Отказ шага не оставляет задачу захваченной до конца срока: захват снимается,
|
||||
// и задача ждёт нарастающую паузу. Иначе повтор наступал бы через восемь часов.
|
||||
func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
|
||||
// Источник метаданных отказывает — это отказ шага, а не приговор записи.
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
job := newTelegramJob(t, env)
|
||||
|
||||
// Ссылку переставляем на запись без содержимого: шаг отказывает на получении
|
||||
// рабочей копии — то есть отказом, а не приговором записи.
|
||||
empty, err := env.fileRepo.CreateRemote("object-key", 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
record, err := env.app.FindRecordById(pbrepo.JobsCollection, job.Id)
|
||||
require.NoError(t, err)
|
||||
record.Set("file", empty.Id)
|
||||
require.NoError(t, env.app.Save(record))
|
||||
|
||||
// Первый отказ.
|
||||
require.Error(t, env.service.FindAndRunConversionJob())
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, after.AcquisitionID, "захват снят: задача пригодна к повтору")
|
||||
require.NotNil(t, after.DelayTime, "пауза поставлена")
|
||||
|
||||
firstDelay := time.Until(*after.DelayTime)
|
||||
|
||||
// Второй отказ — с той же задачи, пауза снята вручную.
|
||||
clearDelay(t, env, job.Id)
|
||||
require.Error(t, env.service.FindAndRunConversionJob())
|
||||
|
||||
after, err = env.jobRepo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, after.DelayTime)
|
||||
|
||||
secondDelay := time.Until(*after.DelayTime)
|
||||
assert.Greater(t, secondDelay, firstDelay, "вторая пауза длиннее первой")
|
||||
}
|
||||
|
||||
// Пауза растёт с числом попыток и упирается в потолок.
|
||||
func TestRetryDelayGrowsAndCaps(t *testing.T) {
|
||||
assert.Equal(t, retryDelayBase, retryDelay(1))
|
||||
assert.Equal(t, 2*retryDelayBase, retryDelay(2))
|
||||
assert.Greater(t, retryDelay(3), retryDelay(2))
|
||||
assert.Equal(t, retryDelayCap, retryDelay(100), "пауза упирается в потолок")
|
||||
assert.Equal(t, retryDelayBase, retryDelay(0), "нулевая попытка не даёт нулевой паузы")
|
||||
}
|
||||
|
||||
// Рабочая копия убирается на любом исходе, включая отказ. Забытая копия — это
|
||||
// шестичасовая запись во временном каталоге, и узнать о ней неоткуда.
|
||||
func TestWorkFileRemovedAfterIntakeFailure(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
|
||||
env := newPipelineEnv(t, &failingMetaViewer{}, &failingConverter{})
|
||||
|
||||
_, err := env.service.CreateJobFromApi(strings.NewReader("запись"), "sample.mp3")
|
||||
require.Error(t, err, "отказ источника метаданных роняет приём")
|
||||
|
||||
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, leftovers, "рабочей копии после отказа не остаётся")
|
||||
}
|
||||
|
||||
// Успешный приём тоже за собой убирает: копия нужна была только на время
|
||||
// укладки в хранилище.
|
||||
func TestWorkFileRemovedAfterSuccessfulIntake(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
_, err := env.service.CreateJobFromApi(strings.NewReader("запись"), "sample.mp3")
|
||||
require.NoError(t, err)
|
||||
|
||||
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, leftovers, "рабочей копии после успеха не остаётся")
|
||||
}
|
||||
|
||||
// Задача не остаётся ссылающейся на файл, которого нет: ссылка переставляется
|
||||
// только после того, как запись о новом файле существует.
|
||||
func TestJobNeverPointsToMissingFile(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
job := newTelegramJob(t, env)
|
||||
|
||||
// Конвертация отказывает — задача уходит в `failed`, но ссылка остаётся на
|
||||
// исходную запись, а не на несозданный результат.
|
||||
require.NoError(t, env.service.FindAndRunConversionJob())
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateFailed, after.State)
|
||||
require.NotNil(t, after.FileID)
|
||||
|
||||
file, err := env.fileRepo.GetByID(*after.FileID)
|
||||
require.NoError(t, err, "ссылка задачи ведёт на существующую запись о файле")
|
||||
assert.NotEmpty(t, file.FileName)
|
||||
}
|
||||
|
||||
// Содержимое доезжает до хранилища целиком и читается обратно тем же.
|
||||
func TestStoredContentSurvivesRoundTrip(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
content := strings.Repeat("запись ", 1000)
|
||||
|
||||
job, err := env.service.CreateJobFromApi(strings.NewReader(content), "sample.mp3")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, job.FileID)
|
||||
|
||||
reader, err := env.fileRepo.Open(*job.FileID)
|
||||
require.NoError(t, err)
|
||||
defer reader.Close()
|
||||
|
||||
stored, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, content, string(stored))
|
||||
|
||||
// И длина в учёте совпадает с длиной принятого.
|
||||
file, err := env.fileRepo.GetByID(*job.FileID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(len(content)), file.Size)
|
||||
}
|
||||
|
||||
// Рабочая копия хранимого файла отдаётся именем на диске — так её получают
|
||||
// шаги, отдающие файл внешней программе.
|
||||
func TestLocalizeGivesReadableCopy(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
job, err := env.service.CreateJobFromApi(strings.NewReader("содержимое"), "sample.mp3")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, job.FileID)
|
||||
|
||||
work, err := env.fileRepo.Localize(*job.FileID)
|
||||
require.NoError(t, err)
|
||||
|
||||
content, err := os.ReadFile(work.Path())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "содержимое", string(content))
|
||||
|
||||
require.NoError(t, work.Close())
|
||||
_, err = os.Stat(work.Path())
|
||||
assert.True(t, os.IsNotExist(err), "закрытая копия убрана")
|
||||
}
|
||||
|
||||
// Уборка рабочей копии проверяется и на шаге конвертации: репозиторий даёт
|
||||
// единственный способ убрать копию, но зовёт его шаг, и норма держится
|
||||
// проверкой, а не построением. Копий здесь две — исходник и результат.
|
||||
func TestWorkFilesRemovedAfterConversionFailure(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
newTelegramJob(t, env)
|
||||
|
||||
// Приём уже отработал — убеждаемся, что за собой он прибрал, иначе остаток
|
||||
// от него зачёлся бы шагу конвертации.
|
||||
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, leftovers, "приём убрал свою рабочую копию")
|
||||
|
||||
// Конвертация отказывает — задача уходит в `failed`, копии убраны.
|
||||
require.NoError(t, env.service.FindAndRunConversionJob())
|
||||
|
||||
leftovers, err = filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, leftovers, "ни исходной копии, ни копии под результат не осталось")
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Шаги распознавания переписаны переездом на новое хранилище целиком: они берут
|
||||
// содержимое по записи, заводят запись о копии во внешнем хранилище и пишут
|
||||
// результат условием по держателю захвата. Подставной распознаватель проекта
|
||||
// умеет только «завершено с фиксированным текстом», поэтому ветки ожидания,
|
||||
// отказа операции и пустого текста изобразить нечем — для них нужен управляемый
|
||||
// двойник.
|
||||
|
||||
// scriptedRecognizer отдаёт заданный исход проверки операции и заданный текст.
|
||||
type scriptedRecognizer struct {
|
||||
result *entity.RecognitionResult
|
||||
text string
|
||||
recognizeErr error
|
||||
|
||||
recognizeCalls int
|
||||
lastObjectKey string
|
||||
}
|
||||
|
||||
func (r *scriptedRecognizer) Recognize(file io.Reader, fileName string) (string, error) {
|
||||
r.recognizeCalls++
|
||||
r.lastObjectKey = fileName
|
||||
if r.recognizeErr != nil {
|
||||
return "", r.recognizeErr
|
||||
}
|
||||
// Содержимое обязано быть читаемым: шаг отдаёт его наружу потоком.
|
||||
if _, err := io.Copy(io.Discard, file); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "operation-id", nil
|
||||
}
|
||||
|
||||
func (r *scriptedRecognizer) GetRecognitionText(string) (string, error) {
|
||||
return r.text, nil
|
||||
}
|
||||
|
||||
func (r *scriptedRecognizer) CheckRecognitionStatus(string) (*entity.RecognitionResult, error) {
|
||||
return r.result, nil
|
||||
}
|
||||
|
||||
// convertedJob доводит задачу до состояния, с которого работает шаг
|
||||
// распознавания: запись принята и сконвертирована.
|
||||
func convertedJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob {
|
||||
t.Helper()
|
||||
|
||||
job := newTelegramJob(t, env)
|
||||
|
||||
acquired, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "setup", time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
acquired.MoveToState(entity.StateConverted)
|
||||
require.NoError(t, env.jobRepo.Save(acquired, "setup"))
|
||||
|
||||
return job
|
||||
}
|
||||
|
||||
// withRecognizer пересобирает сервис с управляемым распознавателем поверх того
|
||||
// же хранилища.
|
||||
func withRecognizer(env *pipelineEnv, rec contract.AudioRecognizer) *TranscribeService {
|
||||
return NewTranscribeService(
|
||||
env.jobRepo,
|
||||
env.fileRepo,
|
||||
&okMetaViewer{},
|
||||
&failingConverter{},
|
||||
rec,
|
||||
env.sender,
|
||||
env.service.logger,
|
||||
)
|
||||
}
|
||||
|
||||
// Шаг распознавания отдаёт содержимое наружу, заводит запись о копии во внешнем
|
||||
// хранилище и переставляет на неё ссылку задачи — только после того, как запись
|
||||
// о копии существует.
|
||||
func TestTranscribeJobHandsRecordOverAndMovesOn(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
job := convertedJob(t, env)
|
||||
|
||||
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
|
||||
svc := withRecognizer(env, rec)
|
||||
|
||||
require.NoError(t, svc.FindAndRunTranscribeJob())
|
||||
|
||||
assert.Equal(t, 1, rec.recognizeCalls, "содержимое отдано распознавателю")
|
||||
assert.NotEmpty(t, rec.lastObjectKey, "ключ объекта назван")
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateTranscribe, after.State)
|
||||
require.NotNil(t, after.RecognitionOpID)
|
||||
assert.Equal(t, "operation-id", *after.RecognitionOpID)
|
||||
require.NotNil(t, after.DelayTime, "задержка перед первой проверкой поставлена")
|
||||
|
||||
// Ссылка задачи ведёт на существующую запись о копии, а не на несозданную.
|
||||
require.NotNil(t, after.FileID)
|
||||
copyRecord, err := env.fileRepo.GetByID(*after.FileID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.LocationS3, copyRecord.Location)
|
||||
}
|
||||
|
||||
// Отказ распознавателя не двигает задачу: она остаётся пригодной к повтору.
|
||||
func TestTranscribeJobKeepsJobRetryableOnRecognizerFailure(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
job := convertedJob(t, env)
|
||||
|
||||
rec := &scriptedRecognizer{recognizeErr: errors.New("распознаватель недоступен")}
|
||||
svc := withRecognizer(env, rec)
|
||||
|
||||
require.Error(t, svc.FindAndRunTranscribeJob())
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateConverted, after.State, "задача осталась на своём шаге")
|
||||
assert.Nil(t, after.AcquisitionID, "захват снят: задача пригодна к повтору")
|
||||
assert.NotNil(t, after.DelayTime, "пауза перед повтором поставлена")
|
||||
assert.Empty(t, env.sender.messages, "отправителю про повторимый отказ не пишут")
|
||||
}
|
||||
|
||||
// transcribingJob доводит задачу до состояния ожидания операции.
|
||||
func transcribingJob(t *testing.T, env *pipelineEnv, rec contract.AudioRecognizer) *entity.TranscribeJob {
|
||||
t.Helper()
|
||||
|
||||
job := convertedJob(t, env)
|
||||
require.NoError(t, withRecognizer(env, rec).FindAndRunTranscribeJob())
|
||||
|
||||
clearDelay(t, env, job.Id)
|
||||
return job
|
||||
}
|
||||
|
||||
// Ожидание чужой операции попытку не тратит и опрос не учащает: шаг отработал
|
||||
// без отказа, и задержка у него своя, числом.
|
||||
func TestCheckJobWaitsWithoutSpendingAttempts(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
|
||||
job := transcribingJob(t, env, rec)
|
||||
|
||||
svc := withRecognizer(env, rec)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob())
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateTranscribe, after.State)
|
||||
assert.Equal(t, 0, after.Attempts, "ожидание операции попытку не тратит")
|
||||
require.NotNil(t, after.DelayTime)
|
||||
assert.InDelta(t, nextCheckDelay.Seconds(), time.Until(*after.DelayTime).Seconds(), 2,
|
||||
"задержка опроса не выродилась в наименьшую паузу повтора")
|
||||
|
||||
clearDelay(t, env, job.Id)
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ операции распознавания — приговор записи: задача уходит в `failed`, а
|
||||
// отправитель узнаёт причину человеческим текстом.
|
||||
func TestCheckJobFailsJobAndTellsSender(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
|
||||
job := transcribingJob(t, env, rec)
|
||||
|
||||
rec.result = entity.NewFailedResult("операция отклонена")
|
||||
svc := withRecognizer(env, rec)
|
||||
|
||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob())
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateFailed, after.State)
|
||||
|
||||
require.Len(t, env.sender.messages, 1, "отправитель узнал об отказе")
|
||||
assert.Contains(t, env.sender.messages[0], "сбой при распознавании файла")
|
||||
assert.NotContains(t, env.sender.messages[0], "операция отклонена",
|
||||
"машинная причина отправителю не идёт")
|
||||
}
|
||||
|
||||
// Готовая операция завершает задачу и отдаёт текст отправителю ровно один раз.
|
||||
func TestCheckJobCompletesAndAnswersOnce(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
|
||||
job := transcribingJob(t, env, rec)
|
||||
|
||||
rec.result = entity.NewCompletedResult()
|
||||
rec.text = "расшифровка записи"
|
||||
svc := withRecognizer(env, rec)
|
||||
|
||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob())
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateDone, after.State)
|
||||
require.NotNil(t, after.TranscriptionText)
|
||||
assert.Equal(t, "расшифровка записи", *after.TranscriptionText)
|
||||
|
||||
require.Len(t, env.sender.messages, 1, "отправитель получил ровно один ответ")
|
||||
assert.Equal(t, "расшифровка записи", env.sender.messages[0])
|
||||
|
||||
// И задача из выборки исчезла: второй ответ отправителю неоткуда взяться.
|
||||
_, err = env.jobRepo.FindAndAcquire(entity.StateTranscribe, "next", time.Now().Add(-time.Hour))
|
||||
var missing *contract.JobNotFoundError
|
||||
assert.ErrorAs(t, err, &missing)
|
||||
}
|
||||
|
||||
// Пустая расшифровка — не отказ: задача завершается, а отправителю уходит
|
||||
// объяснение вместо пустого сообщения.
|
||||
func TestCheckJobCompletesEmptyTextWithExplanation(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
|
||||
job := transcribingJob(t, env, rec)
|
||||
|
||||
rec.result = entity.NewCompletedResult()
|
||||
rec.text = ""
|
||||
svc := withRecognizer(env, rec)
|
||||
|
||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob())
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateDone, after.State)
|
||||
|
||||
require.Len(t, env.sender.messages, 1)
|
||||
assert.Contains(t, strings.ToLower(env.sender.messages[0]), "нет текста")
|
||||
}
|
||||
|
||||
// Шаг, потерявший захват за время работы, результата не пишет и отправителю не
|
||||
// отвечает: иначе два воркера пишут в одну задачу, а отправитель получает два
|
||||
// ответа на одну запись.
|
||||
func TestCheckJobWritesNothingWhenAcquisitionLost(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
|
||||
job := transcribingJob(t, env, rec)
|
||||
|
||||
rec.result = entity.NewCompletedResult()
|
||||
rec.text = "расшифровка записи"
|
||||
|
||||
// Захват задачи достался другому, пока шаг работал.
|
||||
acquired, err := env.jobRepo.FindAndAcquire(entity.StateTranscribe, "mine", time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err)
|
||||
|
||||
record, err := env.app.FindRecordById(pocketbase.JobsCollection, job.Id)
|
||||
require.NoError(t, err)
|
||||
record.Set("acquisition_id", "someone-else")
|
||||
require.NoError(t, env.app.Save(record))
|
||||
|
||||
svc := withRecognizer(env, rec)
|
||||
err = svc.checkTranscribeJob(acquired, "mine")
|
||||
|
||||
var lost *contract.LostAcquisitionError
|
||||
require.ErrorAs(t, err, &lost)
|
||||
|
||||
after, err := env.jobRepo.GetByID(job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateTranscribe, after.State, "результат не записан")
|
||||
assert.Empty(t, env.sender.messages, "отправителю ничего не отправлено")
|
||||
}
|
||||
+262
-187
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -18,17 +18,39 @@ import (
|
||||
|
||||
const (
|
||||
defaultAudioExt = "audio"
|
||||
|
||||
// Предел попыток. Число обратимо и живёт здесь одним местом; счётчик растёт
|
||||
// при захвате и обнуляется на шаге, завершившемся без отказа.
|
||||
maxAttempts = 5
|
||||
|
||||
// Пауза перед повтором отказавшей задачи растёт с числом попыток до
|
||||
// потолка. Ожидание чужой операции этой паузой не выражается — у него своя
|
||||
// задержка числом, и попытки оно не тратит.
|
||||
retryDelayBase = time.Second
|
||||
retryDelayCap = 5 * time.Minute
|
||||
|
||||
// Сроки захвата. Каждый не меньше того, что его шаг может занять на самом
|
||||
// длинном допустимом входе: расчётный потолок записи — шесть часов, и
|
||||
// конвертация такой записи идёт дольше часа по построению.
|
||||
conversionAcquireTimeout = 8 * time.Hour
|
||||
transcribeAcquireTimeout = 8 * time.Hour
|
||||
checkAcquireTimeout = time.Hour
|
||||
|
||||
// Задержки опроса операции распознавания. Числа, а не функция числа попыток:
|
||||
// счётчик на ожидании обнулён, и выведенная из него пауза выродилась бы в
|
||||
// своё наименьшее значение, учащая опрос платного сервиса.
|
||||
firstCheckDelay = 10 * time.Second
|
||||
nextCheckDelay = 5 * time.Second
|
||||
)
|
||||
|
||||
type TranscribeService struct {
|
||||
jobRepo contract.TranscriptJobRepository
|
||||
fileRepo contract.FileRepository
|
||||
metaviewer contract.AudioMetaViewer
|
||||
converter contract.AudioFileConverter
|
||||
recognizer contract.AudioRecognizer
|
||||
tgSender contract.TelegramMessageSender
|
||||
storagePath string
|
||||
logger *slog.Logger
|
||||
jobRepo contract.TranscriptJobRepository
|
||||
fileRepo contract.FileRepository
|
||||
metaviewer contract.AudioMetaViewer
|
||||
converter contract.AudioFileConverter
|
||||
recognizer contract.AudioRecognizer
|
||||
tgSender contract.TelegramMessageSender
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewTranscribeService(
|
||||
@@ -38,172 +60,172 @@ func NewTranscribeService(
|
||||
converter contract.AudioFileConverter,
|
||||
recognizer contract.AudioRecognizer,
|
||||
tgSender contract.TelegramMessageSender,
|
||||
storagePath string,
|
||||
logger *slog.Logger,
|
||||
) *TranscribeService {
|
||||
return &TranscribeService{
|
||||
jobRepo: jobRepo,
|
||||
fileRepo: fileRepo,
|
||||
metaviewer: metaviewer,
|
||||
converter: converter,
|
||||
recognizer: recognizer,
|
||||
tgSender: tgSender,
|
||||
storagePath: storagePath,
|
||||
logger: logger,
|
||||
jobRepo: jobRepo,
|
||||
fileRepo: fileRepo,
|
||||
metaviewer: metaviewer,
|
||||
converter: converter,
|
||||
recognizer: recognizer,
|
||||
tgSender: tgSender,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TranscribeService) CreateJobFromTelegram(file io.Reader, fileName string, chatId int64, replyMsgId int) (*entity.TranscribeJob, error) {
|
||||
jobId := uuid.NewString()
|
||||
now := time.Now()
|
||||
|
||||
job := &entity.TranscribeJob{
|
||||
Id: jobId,
|
||||
State: entity.StateCreated,
|
||||
Source: entity.SourceTelegram,
|
||||
TgChatId: &chatId,
|
||||
TgReplyMessageId: &replyMsgId,
|
||||
IsError: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
return s.createTranscribeJob(job, file, fileName)
|
||||
}
|
||||
|
||||
func (s *TranscribeService) CreateJobFromApi(file io.Reader, fileName string) (*entity.TranscribeJob, error) {
|
||||
jobId := uuid.NewString()
|
||||
now := time.Now()
|
||||
|
||||
job := &entity.TranscribeJob{
|
||||
Id: jobId,
|
||||
State: entity.StateCreated,
|
||||
Source: entity.SourceApi,
|
||||
IsError: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
State: entity.StateCreated,
|
||||
Source: entity.SourceApi,
|
||||
}
|
||||
|
||||
return s.createTranscribeJob(job, file, fileName)
|
||||
}
|
||||
|
||||
func (s *TranscribeService) createTranscribeJob(job *entity.TranscribeJob, file io.Reader, fileName string) (*entity.TranscribeJob, error) {
|
||||
// Генерируем UUID для файла
|
||||
fileId := uuid.NewString()
|
||||
|
||||
// Определяем расширение файла
|
||||
ext := filepath.Ext(fileName)
|
||||
if ext == "" {
|
||||
ext = fmt.Sprintf(".%s", defaultAudioExt) // fallback если расширение не определено
|
||||
}
|
||||
|
||||
// Создаем путь для сохранения файла
|
||||
// Собственное имя записи: идентификатор с расширением. Имя, данное
|
||||
// отправителем, в хранилище не попадает — от него взято только расширение.
|
||||
fileId := uuid.NewString()
|
||||
storageFileName := fmt.Sprintf("%s%s", fileId, ext)
|
||||
storageFilePath := filepath.Join(s.storagePath, storageFileName)
|
||||
|
||||
// Имя, данное отправителем, в журнал не идёт: инвариант приватности.
|
||||
// Расширение из него уже стоит в собственном имени файла на диске.
|
||||
s.logger.Info("Creating transcribe job",
|
||||
"file_id", fileId,
|
||||
"storage_path", storageFilePath)
|
||||
|
||||
// Создаем файл на диске
|
||||
dst, err := os.Create(storageFilePath)
|
||||
// Содержимое ложится в рабочую копию потоком: в память запись целиком не
|
||||
// читается, расчётный потолок — шесть часов.
|
||||
work, err := s.fileRepo.Stage(ext, file)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to create file", "error", err, "path", storageFilePath)
|
||||
s.logger.Error("Failed to stage uploaded file", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
defer dst.Close()
|
||||
defer s.closeWork(work)
|
||||
|
||||
// Копируем содержимое загруженного файла
|
||||
size, err := io.Copy(dst, file)
|
||||
// В журнал идёт расширение, и только оно. Имя, данное отправителем, не
|
||||
// пишется по инварианту приватности; имя, под которым файл ложится в
|
||||
// хранилище, — потому что оно последняя часть ссылки на скачивание, и
|
||||
// строка журнала вместе с идентификатором записи собрала бы её целиком.
|
||||
s.logger.Info("Creating transcribe job", "file_ext", ext)
|
||||
|
||||
info, err := s.metaviewer.GetInfo(work.Path())
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to copy file content", "error", err)
|
||||
s.logger.Error("Failed to get file info", "error", err, "file_ext", ext)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := dst.Close(); err != nil {
|
||||
s.logger.Error("Failed to close file", "error", err)
|
||||
size, err := work.Size()
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to measure uploaded file", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info, err := s.metaviewer.GetInfo(storageFilePath)
|
||||
fileRecord, err := s.fileRepo.CreateLocal(storageFileName, work)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get file info", "error", err, "path", storageFilePath)
|
||||
s.logger.Error("Failed to create file record", "error", err, "file_ext", ext)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("File uploaded successfully",
|
||||
"file_id", fileId,
|
||||
"file_id", fileRecord.Id,
|
||||
"size", size,
|
||||
"duration_seconds", info.Seconds)
|
||||
|
||||
metrics.InputFileDurationHistogram.WithLabelValues().Observe(float64(info.Seconds))
|
||||
metrics.ObserveInputFileSize(ext, size)
|
||||
|
||||
// Создаем запись в таблице files
|
||||
fileRecord := &entity.File{
|
||||
Id: fileId,
|
||||
Storage: entity.StorageLocal,
|
||||
FileName: storageFileName,
|
||||
Size: size,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.fileRepo.Create(fileRecord); err != nil {
|
||||
// Удаляем файл если не удалось создать запись в БД
|
||||
os.Remove(storageFilePath)
|
||||
s.logger.Error("Failed to create file record", "error", err, "file_id", fileId)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
job.FileID = &fileId
|
||||
job.FileID = &fileRecord.Id
|
||||
|
||||
if err := s.jobRepo.Create(job); err != nil {
|
||||
s.logger.Error("Failed to create job record", "error", err, "job_id", job.Id)
|
||||
s.logger.Error("Failed to create job record", "error", err, "file_id", fileRecord.Id)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("Transcribe job created successfully", "job_id", job.Id, "file_id", fileId)
|
||||
s.logger.Info("Transcribe job created successfully", "job_id", job.Id, "file_id", fileRecord.Id)
|
||||
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func (s *TranscribeService) FindAndRunConversionJob() error {
|
||||
job, err := s.findJob(entity.StateCreated, time.Hour)
|
||||
return s.runStep(entity.StateCreated, conversionAcquireTimeout, s.convertJob)
|
||||
}
|
||||
|
||||
func (s *TranscribeService) FindAndRunTranscribeJob() error {
|
||||
return s.runStep(entity.StateConverted, transcribeAcquireTimeout, s.transcribeJob)
|
||||
}
|
||||
|
||||
func (s *TranscribeService) FindAndRunTranscribeCheckJob() error {
|
||||
return s.runStep(entity.StateTranscribe, checkAcquireTimeout, s.checkTranscribeJob)
|
||||
}
|
||||
|
||||
// runStep забирает задачу и отдаёт её шагу. Отказ шага не оставляет задачу
|
||||
// захваченной до конца срока: захват снимается, и задача ждёт нарастающую паузу
|
||||
// — иначе повтор наступал бы через восемь часов, а не через секунду.
|
||||
func (s *TranscribeService) runStep(state string, expiration time.Duration, step func(job *entity.TranscribeJob, holder string) error) error {
|
||||
job, holder, err := s.findJob(state, expiration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := step(job, holder); err != nil {
|
||||
s.scheduleRetry(job, holder, err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TranscribeService) convertJob(job *entity.TranscribeJob, holder string) error {
|
||||
s.logger.Info("Starting conversion job", "job_id", job.Id)
|
||||
|
||||
if job.FileID == nil {
|
||||
s.logger.Error("Job has no file", "job_id", job.Id)
|
||||
return s.failJob(job, holder, errors.New("job has no file"), "у задачи нет записи")
|
||||
}
|
||||
|
||||
srcFile, err := s.fileRepo.GetByID(*job.FileID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get source file", "error", err, "file_id", *job.FileID)
|
||||
return err
|
||||
}
|
||||
|
||||
srcFilePath := filepath.Join(s.storagePath, srcFile.FileName)
|
||||
|
||||
destFileId := uuid.NewString()
|
||||
destFileName := fmt.Sprintf("%s%s", destFileId, ".ogg")
|
||||
destFilePath := filepath.Join(s.storagePath, destFileName)
|
||||
|
||||
// Получаем расширение исходного файла для метрики
|
||||
srcExt := strings.TrimPrefix(filepath.Ext(srcFile.FileName), ".")
|
||||
if srcExt == "" {
|
||||
srcExt = defaultAudioExt
|
||||
}
|
||||
|
||||
s.logger.Info("Converting file",
|
||||
"job_id", job.Id,
|
||||
"src_path", srcFilePath,
|
||||
"dest_path", destFilePath,
|
||||
"src_format", srcExt)
|
||||
src, err := s.fileRepo.Localize(*job.FileID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to localize source file", "error", err, "file_id", *job.FileID)
|
||||
return err
|
||||
}
|
||||
defer s.closeWork(src)
|
||||
|
||||
dest, err := s.fileRepo.StageEmpty(".ogg")
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to stage converted file", "error", err, "job_id", job.Id)
|
||||
return err
|
||||
}
|
||||
defer s.closeWork(dest)
|
||||
|
||||
s.logger.Info("Converting file", "job_id", job.Id, "src_format", srcExt)
|
||||
|
||||
// Измеряем время конвертации
|
||||
startTime := time.Now()
|
||||
err = s.converter.Convert(srcFilePath, destFilePath)
|
||||
err = s.converter.Convert(src.Path(), dest.Path())
|
||||
conversionDuration := time.Since(startTime)
|
||||
|
||||
// Записываем метрику времени конвертации
|
||||
@@ -214,43 +236,36 @@ func (s *TranscribeService) FindAndRunConversionJob() error {
|
||||
"error", err,
|
||||
"job_id", job.Id,
|
||||
"duration", conversionDuration)
|
||||
return s.failJob(job, err, "сбой конвертации файла")
|
||||
return s.failJob(job, holder, err, "сбой конвертации файла")
|
||||
}
|
||||
|
||||
stat, err := os.Stat(destFilePath)
|
||||
destSize, err := dest.Size()
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to stat converted file", "error", err, "path", destFilePath)
|
||||
s.logger.Error("Failed to measure converted file", "error", err, "job_id", job.Id)
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("File conversion completed",
|
||||
"job_id", job.Id,
|
||||
"duration", conversionDuration,
|
||||
"output_size", stat.Size())
|
||||
"output_size", destSize)
|
||||
|
||||
// Записываем метрику размера выходного файла
|
||||
metrics.OutputFileSizeHistogram.WithLabelValues("ogg").Observe(float64(stat.Size()))
|
||||
metrics.OutputFileSizeHistogram.WithLabelValues("ogg").Observe(float64(destSize))
|
||||
|
||||
// Создаем запись в таблице files
|
||||
destFileRecord := &entity.File{
|
||||
Id: destFileId,
|
||||
Storage: entity.StorageLocal,
|
||||
FileName: destFileName,
|
||||
Size: stat.Size(),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
job.FileID = &destFileId
|
||||
job.MoveToState(entity.StateConverted)
|
||||
|
||||
err = s.fileRepo.Create(destFileRecord)
|
||||
destFileName := fmt.Sprintf("%s%s", uuid.NewString(), ".ogg")
|
||||
destFileRecord, err := s.fileRepo.CreateLocal(destFileName, dest)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to create converted file record", "error", err, "file_id", destFileId)
|
||||
s.logger.Error("Failed to create converted file record", "error", err, "job_id", job.Id)
|
||||
return err
|
||||
}
|
||||
|
||||
err = s.jobRepo.Save(job)
|
||||
if err != nil {
|
||||
// Ссылка переставляется только после того, как запись о новом файле есть:
|
||||
// иначе повтор оставил бы задачу указывающей на файл, которого нет.
|
||||
job.FileID = &destFileRecord.Id
|
||||
job.MoveToState(entity.StateConverted)
|
||||
|
||||
if err := s.jobRepo.Save(job, holder); err != nil {
|
||||
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
|
||||
return err
|
||||
}
|
||||
@@ -259,36 +274,35 @@ func (s *TranscribeService) FindAndRunConversionJob() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TranscribeService) FindAndRunTranscribeJob() error {
|
||||
job, err := s.findJob(entity.StateConverted, time.Hour)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TranscribeService) transcribeJob(job *entity.TranscribeJob, holder string) error {
|
||||
s.logger.Info("Starting transcribe job", "job_id", job.Id)
|
||||
|
||||
if job.FileID == nil {
|
||||
s.logger.Error("Job has no file", "job_id", job.Id)
|
||||
return s.failJob(job, holder, errors.New("job has no file"), "у задачи нет записи")
|
||||
}
|
||||
|
||||
fileRecord, err := s.fileRepo.GetByID(*job.FileID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get file record", "error", err, "file_id", *job.FileID)
|
||||
return err
|
||||
}
|
||||
|
||||
filePath := filepath.Join(s.storagePath, fileRecord.FileName)
|
||||
|
||||
file, err := os.Open(filePath)
|
||||
content, err := s.fileRepo.Open(*job.FileID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to open file", "error", err, "path", filePath)
|
||||
s.logger.Error("Failed to open file", "error", err, "file_id", *job.FileID)
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
defer func() {
|
||||
if err := content.Close(); err != nil {
|
||||
s.logger.Error("Failed to close file", "error", err, "file_id", *job.FileID)
|
||||
}
|
||||
}()
|
||||
|
||||
destFileId := uuid.NewString()
|
||||
destFileRecord := fileRecord.CopyWithStorage(destFileId, entity.StorageS3)
|
||||
|
||||
s.logger.Info("Starting recognition", "job_id", job.Id, "file_path", filePath)
|
||||
s.logger.Info("Starting recognition", "job_id", job.Id, "file_id", *job.FileID)
|
||||
|
||||
// Запускаем асинхронное распознавание
|
||||
operationID, err := s.recognizer.Recognize(file, destFileRecord.FileName)
|
||||
operationID, err := s.recognizer.Recognize(content, fileRecord.FileName)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to start recognition", "error", err, "job_id", job.Id)
|
||||
return err
|
||||
@@ -298,20 +312,19 @@ func (s *TranscribeService) FindAndRunTranscribeJob() error {
|
||||
"job_id", job.Id,
|
||||
"operation_id", operationID)
|
||||
|
||||
// Обновляем задачу с ID операции распознавания
|
||||
job.FileID = &destFileId
|
||||
job.RecognitionOpID = &operationID
|
||||
delayTime := time.Now().Add(10 * time.Second)
|
||||
job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
|
||||
|
||||
err = s.fileRepo.Create(destFileRecord)
|
||||
destFileRecord, err := s.fileRepo.CreateRemote(fileRecord.FileName, fileRecord.Size)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to create S3 file record", "error", err, "file_id", destFileId)
|
||||
s.logger.Error("Failed to create S3 file record", "error", err, "job_id", job.Id)
|
||||
return err
|
||||
}
|
||||
|
||||
err = s.jobRepo.Save(job)
|
||||
if err != nil {
|
||||
// Обновляем задачу с ID операции распознавания
|
||||
job.FileID = &destFileRecord.Id
|
||||
job.RecognitionOpID = &operationID
|
||||
delayTime := time.Now().Add(firstCheckDelay)
|
||||
job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
|
||||
|
||||
if err := s.jobRepo.Save(job, holder); err != nil {
|
||||
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
|
||||
return err
|
||||
}
|
||||
@@ -320,12 +333,7 @@ func (s *TranscribeService) FindAndRunTranscribeJob() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TranscribeService) FindAndRunTranscribeCheckJob() error {
|
||||
job, err := s.findJob(entity.StateTranscribe, 24*time.Hour)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TranscribeService) checkTranscribeJob(job *entity.TranscribeJob, holder string) error {
|
||||
if job.RecognitionOpID == nil {
|
||||
s.logger.Error("Recognition operation ID not found", "job_id", job.Id)
|
||||
return fmt.Errorf("recogniton opId not found for job: %s", job.Id)
|
||||
@@ -342,12 +350,13 @@ func (s *TranscribeService) FindAndRunTranscribeCheckJob() error {
|
||||
}
|
||||
|
||||
if recResult.IsInProgress() {
|
||||
// Операция еще не завершена, оставляем в статусе обработки
|
||||
// Операция ещё не завершена. Шаг отработал без отказа, поэтому задержка
|
||||
// здесь своя, числом, а число попыток обнуляется переходом: ожидание
|
||||
// чужой операции попытку не тратит.
|
||||
s.logger.Info("Operation in progress", "job_id", job.Id, "operation_id", opId)
|
||||
delayTime := time.Now().Add(5 * time.Second)
|
||||
delayTime := time.Now().Add(nextCheckDelay)
|
||||
job.MoveToStateAndDelay(entity.StateTranscribe, &delayTime)
|
||||
err := s.jobRepo.Save(job)
|
||||
if err != nil {
|
||||
if err := s.jobRepo.Save(job, holder); err != nil {
|
||||
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
|
||||
return err
|
||||
}
|
||||
@@ -360,7 +369,7 @@ func (s *TranscribeService) FindAndRunTranscribeCheckJob() error {
|
||||
"job_id", job.Id,
|
||||
"operation_id", opId,
|
||||
"error_message", errorText)
|
||||
return s.failJob(job, errors.New(errorText), "сбой при распознавании файла")
|
||||
return s.failJob(job, holder, errors.New(errorText), "сбой при распознавании файла")
|
||||
}
|
||||
|
||||
// Операция завершена, получаем результат
|
||||
@@ -376,86 +385,152 @@ func (s *TranscribeService) FindAndRunTranscribeCheckJob() error {
|
||||
"text_length", len(transcriptionText))
|
||||
|
||||
if len(transcriptionText) == 0 {
|
||||
return s.completeJob(job, "Ой, кажется, на аудиозаписи нет текста.")
|
||||
return s.completeJob(job, holder, "Ой, кажется, на аудиозаписи нет текста.")
|
||||
}
|
||||
|
||||
// Завершаем задачу
|
||||
return s.completeJob(job, transcriptionText)
|
||||
return s.completeJob(job, holder, transcriptionText)
|
||||
}
|
||||
|
||||
func (s *TranscribeService) findJob(state string, expiration time.Duration) (job *entity.TranscribeJob, err error) {
|
||||
// findJob забирает задачу и отдаёт её вместе с признаком захвата, который шаг
|
||||
// держит. Задача, захваченная сверх предела попыток, до шага не доходит: её
|
||||
// переводят в «мертва» и сообщают об этом отправителю.
|
||||
func (s *TranscribeService) findJob(state string, expiration time.Duration) (*entity.TranscribeJob, string, error) {
|
||||
acquisitionId := uuid.NewString()
|
||||
rottingTime := time.Now().Add(-1 * expiration)
|
||||
|
||||
job, err = s.jobRepo.FindAndAcquire(state, acquisitionId, rottingTime)
|
||||
job, err := s.jobRepo.FindAndAcquire(state, acquisitionId, rottingTime)
|
||||
if err != nil {
|
||||
// Признак узнаётся по смыслу: репозиторий вправе обернуть свой отказ
|
||||
// пояснением, и приведение типа от этого сломалось бы молча.
|
||||
var notFound *contract.JobNotFoundError
|
||||
if errors.As(err, ¬Found) {
|
||||
return nil, &contract.NoopJobError{State: state}
|
||||
return nil, "", &contract.NoopJobError{State: state}
|
||||
}
|
||||
s.logger.Error("Failed to find and acquire job", "state", state, "error", err)
|
||||
return nil, fmt.Errorf("failed find and acquire job: %s, %w", state, err)
|
||||
return nil, "", fmt.Errorf("failed find and acquire job: %s, %w", state, err)
|
||||
}
|
||||
|
||||
return job, nil
|
||||
if job.Attempts > maxAttempts {
|
||||
s.killJob(job, acquisitionId)
|
||||
return nil, "", &contract.NoopJobError{State: state}
|
||||
}
|
||||
|
||||
return job, acquisitionId, nil
|
||||
}
|
||||
|
||||
func (s *TranscribeService) completeJob(job *entity.TranscribeJob, transcriptionText string) error {
|
||||
// killJob переводит исчерпавшую попытки задачу в «мертва» и сообщает об этом
|
||||
// отправителю. Инвариант «Принятая запись не теряется молча» допускает два
|
||||
// исхода — задача пригодна к повтору либо об отказе сказано, — и молчаливая
|
||||
// смерть не подходит ни под один.
|
||||
func (s *TranscribeService) killJob(job *entity.TranscribeJob, holder string) {
|
||||
s.logger.Error("Job exhausted its attempts",
|
||||
"job_id", job.Id,
|
||||
"state", job.State,
|
||||
"attempts", job.Attempts)
|
||||
|
||||
job.Die(fmt.Sprintf("attempts exhausted: %d", job.Attempts))
|
||||
|
||||
if err := s.jobRepo.Save(job, holder); err != nil {
|
||||
s.logger.Error("Failed to save dead job", "error", err, "job_id", job.Id)
|
||||
return
|
||||
}
|
||||
|
||||
s.notify(job, "Не удалось обработать запись: попытки исчерпаны.\nПожалуйста, попробуйте еще раз.")
|
||||
}
|
||||
|
||||
// scheduleRetry снимает захват с отказавшей задачи и ставит нарастающую паузу.
|
||||
// Захват, оставленный до конца срока, отложил бы повтор на часы.
|
||||
func (s *TranscribeService) scheduleRetry(job *entity.TranscribeJob, holder string, stepErr error) {
|
||||
// Шаг, потерявший захват, задачу уже не трогает: ею занят другой.
|
||||
var lost *contract.LostAcquisitionError
|
||||
if errors.As(stepErr, &lost) {
|
||||
return
|
||||
}
|
||||
|
||||
job.RetryAfter(time.Now().Add(retryDelay(job.Attempts)))
|
||||
|
||||
if err := s.jobRepo.Save(job, holder); err != nil {
|
||||
var lostOnSave *contract.LostAcquisitionError
|
||||
if errors.As(err, &lostOnSave) {
|
||||
return
|
||||
}
|
||||
s.logger.Error("Failed to schedule job retry", "error", err, "job_id", job.Id)
|
||||
}
|
||||
}
|
||||
|
||||
// retryDelay растит паузу с числом попыток до потолка.
|
||||
func retryDelay(attempts int) time.Duration {
|
||||
if attempts < 1 {
|
||||
attempts = 1
|
||||
}
|
||||
delay := time.Duration(math.Pow(2, float64(attempts-1))) * retryDelayBase
|
||||
if delay > retryDelayCap || delay <= 0 {
|
||||
return retryDelayCap
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
func (s *TranscribeService) completeJob(job *entity.TranscribeJob, holder string, transcriptionText string) error {
|
||||
// Обновляем задачу с результатом
|
||||
job.Done(transcriptionText)
|
||||
|
||||
// Сохраняем задачу в базу
|
||||
err := s.jobRepo.Save(job)
|
||||
if err != nil {
|
||||
if err := s.jobRepo.Save(job, holder); err != nil {
|
||||
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
|
||||
return fmt.Errorf("failed to save job: %w", err)
|
||||
}
|
||||
|
||||
// Отправляем распознанный текст обратно пользователю
|
||||
switch job.Source {
|
||||
case entity.SourceTelegram:
|
||||
if job.TgChatId == nil {
|
||||
s.logger.Error("Telegram chat not specified", "job_id", job.Id)
|
||||
return fmt.Errorf("tg chat id not specified, job id: %s", job.Id)
|
||||
}
|
||||
err := s.tgSender.Send(transcriptionText, *job.TgChatId, job.TgReplyMessageId)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to sent transcription text to client", "job_id", job.Id)
|
||||
return fmt.Errorf("failed to sent message to client, job id: %s, err: %w", job.Id, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return s.send(job, transcriptionText)
|
||||
}
|
||||
|
||||
func (s *TranscribeService) failJob(job *entity.TranscribeJob, jobErr error, humanErrorText string) error {
|
||||
func (s *TranscribeService) failJob(job *entity.TranscribeJob, holder string, jobErr error, humanErrorText string) error {
|
||||
// Обновляем задачу с результатом
|
||||
job.Fail(jobErr.Error())
|
||||
|
||||
// Сохраняем задачу в базу
|
||||
err := s.jobRepo.Save(job)
|
||||
if err != nil {
|
||||
if err := s.jobRepo.Save(job, holder); err != nil {
|
||||
s.logger.Error("Failed to save job", "error", err, "job_id", job.Id)
|
||||
return fmt.Errorf("failed to save job: %w", err)
|
||||
}
|
||||
|
||||
// Отправляем текст об ошибке пользователю
|
||||
switch job.Source {
|
||||
case entity.SourceTelegram:
|
||||
if job.TgChatId == nil {
|
||||
s.logger.Error("Telegram chat not specified", "job_id", job.Id)
|
||||
return fmt.Errorf("tg chat id not specified, job id: %s", job.Id)
|
||||
}
|
||||
errorMessage := fmt.Sprintf("При обработке задачи произошла ошибка: %s.\nПожалуйста, попробуйте еще раз.", humanErrorText)
|
||||
return s.send(job, errorMessage)
|
||||
}
|
||||
|
||||
errorMessage := fmt.Sprintf("При обработке задачи произошла ошибка: %s.\nПожалуйста, попробуйте еще раз.", humanErrorText)
|
||||
err := s.tgSender.Send(errorMessage, *job.TgChatId, job.TgReplyMessageId)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to sent message to client", "job_id", job.Id)
|
||||
return fmt.Errorf("failed to sent message to client, job id: %s, err: %w", job.Id, err)
|
||||
}
|
||||
// send отвечает отправителю там, откуда пришла запись, и отказ отправки
|
||||
// поднимает вверх: он принадлежит шагу.
|
||||
func (s *TranscribeService) send(job *entity.TranscribeJob, text string) error {
|
||||
if job.Source != entity.SourceTelegram {
|
||||
return nil
|
||||
}
|
||||
|
||||
if job.TgChatId == nil {
|
||||
s.logger.Error("Telegram chat not specified", "job_id", job.Id)
|
||||
return fmt.Errorf("tg chat id not specified, job id: %s", job.Id)
|
||||
}
|
||||
|
||||
if err := s.tgSender.Send(text, *job.TgChatId, job.TgReplyMessageId); err != nil {
|
||||
s.logger.Error("Failed to sent message to client", "job_id", job.Id)
|
||||
return fmt.Errorf("failed to sent message to client, job id: %s, err: %w", job.Id, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// notify отвечает отправителю там, где поднимать отказ некуда: задача уже
|
||||
// доведена до конца, и отказ отправки остаётся записью в журнале владельца.
|
||||
func (s *TranscribeService) notify(job *entity.TranscribeJob, text string) {
|
||||
if err := s.send(job, text); err != nil {
|
||||
s.logger.Error("Failed to notify sender", "error", err, "job_id", job.Id)
|
||||
}
|
||||
}
|
||||
|
||||
// closeWork убирает рабочую копию. Отказ уборки не роняет шаг, но и не
|
||||
// проглатывается: забытая копия это шестичасовая запись во временном каталоге.
|
||||
func (s *TranscribeService) closeWork(work contract.WorkFile) {
|
||||
if err := work.Close(); err != nil {
|
||||
s.logger.Error("Failed to remove work file", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user