внутренняя модель перестроена вокруг аудиозаписи
- audiorecords вместо transcribe_jobs: приложения (texts, structures, recognitions, record_events, topics) живут своими коллекциями, ссылки на исходник и на приведённую копию перестали переставляться - рубеж называет достигнутое, отказ стал признаком остановки с причиной, а сторожей стало двое: число отказов и время в рубеже - воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
This commit is contained in:
@@ -5,67 +5,71 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Путь признака «работы нет» состоит из двух звеньев: репозиторий рождает
|
||||
// «подходящей задачи не нашлось», сервис переводит это в «работы нет», и уже
|
||||
// его читает воркер. Проверки воркера подменяют работу целиком и второе звено
|
||||
// не видят — без этого файла правку в сервисе принимал бы только линтер, а он
|
||||
// судит форму записи, а не то, узнаётся ли признак на самом деле.
|
||||
// «пригодной записи не нашлось», сервис переводит это в «работы нет», и уже его
|
||||
// читает воркер. Проверки воркера подменяют работу целиком и второе звено не
|
||||
// видят — без этого файла правку в сервисе принимал бы только линтер, а он судит
|
||||
// форму записи, а не то, узнаётся ли признак на самом деле.
|
||||
|
||||
// stubJobRepo отдаёт заданную ошибку на запрос задачи. Прочих методов запроса
|
||||
// задачи проверки этого файла не зовут.
|
||||
type stubJobRepo struct {
|
||||
// stubRecordRepo отдаёт заданную ошибку на запрос записи. Прочих методов
|
||||
// проверки этого файла не зовут.
|
||||
type stubRecordRepo struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *stubJobRepo) Create(*entity.TranscribeJob) error { return nil }
|
||||
func (r *stubJobRepo) Save(*entity.TranscribeJob, string) error { return nil }
|
||||
func (r *stubRecordRepo) Create(*entity.AudioRecord) error { return nil }
|
||||
func (r *stubRecordRepo) Save(*entity.AudioRecord, string) error { return nil }
|
||||
|
||||
func (r *stubJobRepo) GetByID(string, string) (*entity.TranscribeJob, error) {
|
||||
func (r *stubRecordRepo) GetByID(string, string) (*entity.AudioRecord, error) {
|
||||
return nil, errors.New("не зовётся этими проверками")
|
||||
}
|
||||
|
||||
func (r *stubJobRepo) FindAndAcquire(string, string, time.Time) (*entity.TranscribeJob, error) {
|
||||
func (r *stubRecordRepo) Get(string) (*entity.AudioRecord, error) {
|
||||
return nil, errors.New("не зовётся этими проверками")
|
||||
}
|
||||
|
||||
func (r *stubRecordRepo) FindAndAcquire([]entity.Stage) (*contract.AcquiredRecord, error) {
|
||||
return nil, r.err
|
||||
}
|
||||
|
||||
func serviceWithRepo(repo contract.TranscriptJobRepository) *TranscribeService {
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
return NewTranscribeService(repo, nil, nil, nil, nil, nil, logger)
|
||||
func serviceWithRepo(repo contract.AudioRecordRepository) *TranscribeService {
|
||||
return NewTranscribeService(
|
||||
Repositories{Records: repo},
|
||||
nil, nil, nil, nil,
|
||||
entity.StuckLimits{},
|
||||
slog.New(slog.DiscardHandler),
|
||||
)
|
||||
}
|
||||
|
||||
// Репозиторий вправе добавить своему отказу пояснение — соседние ветки того же
|
||||
// метода уже оборачивают ошибки `%w` подряд. Пока признак узнавался приведением
|
||||
// типа, первая такая обёртка превратила бы пустой прогон в отказ: воркер начал
|
||||
// бы писать в журнал раз в секунду на каждом из трёх воркеров.
|
||||
func TestFindJobTranslatesWrappedNotFoundToNoop(t *testing.T) {
|
||||
svc := serviceWithRepo(&stubJobRepo{
|
||||
err: fmt.Errorf("find and acquire job: %w",
|
||||
&contract.JobNotFoundError{State: "created", Message: "appropriate job not found"}),
|
||||
// бы писать в журнал раз в секунду на каждом воркере пула.
|
||||
func TestAcquireTranslatesWrappedNotFoundToNoop(t *testing.T) {
|
||||
svc := serviceWithRepo(&stubRecordRepo{
|
||||
err: fmt.Errorf("find and acquire record: %w",
|
||||
&contract.JobNotFoundError{Message: "no record is ready for work"}),
|
||||
})
|
||||
|
||||
_, _, err := svc.findJob("created", time.Minute)
|
||||
_, _, err := svc.acquire()
|
||||
|
||||
var noop *contract.NoopJobError
|
||||
if !errors.As(err, &noop) {
|
||||
t.Fatalf("обёрнутое «задачи нет» не переведено в пустой прогон: получено %v", err)
|
||||
}
|
||||
if noop.State != "created" {
|
||||
t.Errorf("состояние потеряно при переводе: %q", noop.State)
|
||||
t.Fatalf("обёрнутое «работы нет» не переведено в пустой прогон: получено %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Оборотная сторона: настоящий отказ хранилища пустым прогоном считаться не
|
||||
// должен, иначе задача молча крутилась бы в цикле без единой записи.
|
||||
func TestFindJobKeepsRealFailure(t *testing.T) {
|
||||
svc := serviceWithRepo(&stubJobRepo{err: errors.New("database is gone")})
|
||||
// должен, иначе запись молча крутилась бы в цикле без единой записи в журнале.
|
||||
func TestAcquireKeepsRealFailure(t *testing.T) {
|
||||
svc := serviceWithRepo(&stubRecordRepo{err: errors.New("database is gone")})
|
||||
|
||||
_, _, err := svc.findJob("created", time.Minute)
|
||||
_, _, err := svc.acquire()
|
||||
|
||||
var noop *contract.NoopJobError
|
||||
if errors.As(err, &noop) {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Счёт работы конвейера живёт в шаге, а не в воркере: только шаг знает рубеж, с
|
||||
// которого взята запись, а воркеры пула одинаковы и именем ничего не говорят.
|
||||
|
||||
// stageCount читает счётчик работы из общего реестра процесса. Судит реестр, а
|
||||
// не переменную пакета: метка, потерянная в точке употребления, переменную не
|
||||
// ломает, а на странице метрик видна.
|
||||
func stageCount(t *testing.T, stage, errLabel string) float64 {
|
||||
t.Helper()
|
||||
|
||||
families, err := prometheus.DefaultGatherer.Gather()
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, mf := range families {
|
||||
if mf.GetName() != "transcriber_worker_job_count" {
|
||||
continue
|
||||
}
|
||||
for _, m := range mf.GetMetric() {
|
||||
var gotStage, gotErr string
|
||||
for _, label := range m.GetLabel() {
|
||||
switch label.GetName() {
|
||||
case "stage":
|
||||
gotStage = label.GetValue()
|
||||
case "error":
|
||||
gotErr = label.GetValue()
|
||||
}
|
||||
}
|
||||
if gotStage == stage && gotErr == errLabel {
|
||||
return m.GetCounter().GetValue()
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Критерий приёмки 11. Отказ виден с разрезом по шагу: счётчик отказов —
|
||||
// единственный сигнал, по которому владелец сервиса замечает поломку, и без
|
||||
// метки рубежа «падает приведение» и «падает распознавание» стали бы
|
||||
// неразличимы.
|
||||
func TestFailureIsCountedWithStageLabel(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||
|
||||
beforeOk := stageCount(t, entity.StateUploaded, "false")
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
require.NoError(t, env.service.RunStep(t.Context()))
|
||||
require.Equal(t, entity.StateNormalized, readRecord(t, env, record.Id).State)
|
||||
|
||||
assert.InDelta(t, beforeOk+1, stageCount(t, entity.StateUploaded, "false"), 0,
|
||||
"успешный шаг засчитан с меткой своего рубежа")
|
||||
|
||||
// Теперь тот же рубеж, но с отказом.
|
||||
failing := newPipelineEnv(t, &okMetaViewer{}, &failingStepConverter{})
|
||||
beforeErr := stageCount(t, entity.StateUploaded, "true")
|
||||
|
||||
newTelegramRecord(t, failing)
|
||||
require.Error(t, failing.service.RunStep(t.Context()))
|
||||
|
||||
assert.InDelta(t, beforeErr+1, stageCount(t, entity.StateUploaded, "true"), 0,
|
||||
"отказ засчитан с меткой того же рубежа")
|
||||
}
|
||||
|
||||
// failingStepConverter отказывает **отказом шага**, а не приговором записи:
|
||||
// приговор счётчик отказов не растит, потому что шаг при нём отрабатывает.
|
||||
type failingStepConverter struct{}
|
||||
|
||||
func (c *failingStepConverter) Convert(_ context.Context, _, dest string) error {
|
||||
// Файл результата не создаётся вовсе — шаг отказывает на измерении копии.
|
||||
return os.Remove(dest)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package service
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -16,7 +15,7 @@ import (
|
||||
// показывать, а не кому её считать. Сужение остановило бы расшифровку записей
|
||||
// бота вовсе, а записи остальных поставило бы в зависимость от того, кто первым
|
||||
// завёл учётную запись.
|
||||
func TestWorkerTakesJobsOfEveryOwner(t *testing.T) {
|
||||
func TestWorkerTakesRecordsOfEveryOwner(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
first, err := env.service.CreateJobFromApi(t.Context(),
|
||||
@@ -28,42 +27,48 @@ func TestWorkerTakesJobsOfEveryOwner(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Третья пришла ботом, и владельца у неё нет вовсе.
|
||||
third := newTelegramJob(t, env)
|
||||
third := newTelegramRecord(t, env)
|
||||
|
||||
// Срок протухания в прошлом: захваченная задача остаётся за держателем, и
|
||||
// следующий вызов берёт следующую, а не ту же самую.
|
||||
// Захваченная запись остаётся за держателем, и следующий вызов берёт
|
||||
// следующую, а не ту же самую.
|
||||
taken := map[string]bool{}
|
||||
for _, holder := range []string{"one", "two", "three"} {
|
||||
job, err := env.jobRepo.FindAndAcquire(entity.StateCreated, holder, time.Now().Add(-time.Hour))
|
||||
require.NoError(t, err, "воркер берёт задачи подряд, владельцем не сужаясь")
|
||||
taken[job.Id] = true
|
||||
for range 3 {
|
||||
acquired, err := env.recordRepo.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err, "воркер берёт записи подряд, владельцем не сужаясь")
|
||||
taken[acquired.ID] = true
|
||||
}
|
||||
|
||||
assert.True(t, taken[first.Id], "задача первого владельца досталась воркеру")
|
||||
assert.True(t, taken[first.Id], "запись первого владельца досталась воркеру")
|
||||
assert.True(t, taken[second.Id], "и второго")
|
||||
assert.True(t, taken[third.Id], "и задача без владельца")
|
||||
assert.True(t, taken[third.Id], "и запись без владельца")
|
||||
|
||||
_, err = env.jobRepo.FindAndAcquire(entity.StateCreated, "next", time.Now().Add(-time.Hour))
|
||||
_, err = env.recordRepo.FindAndAcquire(entity.WorkingStages())
|
||||
var missing *contract.JobNotFoundError
|
||||
assert.ErrorAs(t, err, &missing, "больше в этом состоянии никого")
|
||||
assert.ErrorAs(t, err, &missing, "больше пригодных к работе записей нет")
|
||||
}
|
||||
|
||||
// Захват читает владельца: снимок задачи, в котором он всегда пуст, был бы
|
||||
// ловушкой для первого же шага, начавшего сохранять задачу целиком, — и сегодня
|
||||
// уже ломал бы файл, который шаг заводит.
|
||||
func TestAcquireCarriesOwner(t *testing.T) {
|
||||
// Захват отдаёт идентификатор и признак своего захвата — не перечень колонок.
|
||||
// Колонки шаг читает сам: иначе всякая новая колонка записи попадала бы под
|
||||
// инвариант проекта о колонках очереди.
|
||||
func TestAcquireReturnsIdentifierAndHolder(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
owner := newOwner(t, env.app)
|
||||
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
|
||||
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
|
||||
require.NoError(t, err)
|
||||
|
||||
acquired, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
|
||||
acquired, err := env.recordRepo.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, job.Id, acquired.Id)
|
||||
|
||||
require.NotNil(t, acquired.OwnerID, "владелец приехал из захвата")
|
||||
assert.Equal(t, owner, *acquired.OwnerID)
|
||||
assert.Equal(t, record.Id, acquired.ID, "захват назвал запись")
|
||||
assert.NotEmpty(t, acquired.Holder, "и признак своего захвата")
|
||||
|
||||
// Колонки читаются отдельным чтением, и владелец среди них.
|
||||
read := readRecord(t, env, record.Id)
|
||||
require.NotNil(t, read.OwnerID)
|
||||
assert.Equal(t, owner, *read.OwnerID)
|
||||
assert.Equal(t, acquired.Holder, *read.AcquisitionID, "признак захвата записан в саму запись")
|
||||
require.NotNil(t, read.AcquireExpiresAt, "срок протухания приехал с рубежом")
|
||||
}
|
||||
|
||||
// Шаг конвейера владельца не затирает: сохранение кладёт только то, чем
|
||||
@@ -72,34 +77,54 @@ func TestPipelineStepKeepsOwner(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
owner := newOwner(t, env.app)
|
||||
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
|
||||
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Отказ конвертации — приговор записи: шаг переводит задачу в `failed` и
|
||||
// сохраняет её. Это сохранение владельца тронуть не должно.
|
||||
require.NoError(t, env.service.FindAndRunConversionJob(t.Context()))
|
||||
// Отказ приведения — приговор записи: шаг останавливает её и сохраняет. Это
|
||||
// сохранение владельца тронуть не должно.
|
||||
require.NoError(t, env.service.RunStep(t.Context()))
|
||||
|
||||
after, err := readJob(env.app, job.Id)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, entity.StateFailed, after.State, "шаг записал свой приговор")
|
||||
after := readRecord(t, env, record.Id)
|
||||
require.True(t, after.IsHalted(), "шаг записал свой приговор")
|
||||
require.NotNil(t, after.OwnerID, "владелец пережил шаг")
|
||||
assert.Equal(t, owner, *after.OwnerID)
|
||||
}
|
||||
|
||||
// Приём из веба без владельца задачи не заводит. Обязательность держит здесь
|
||||
// Приём из веба без владельца записи не заводит. Обязательность держит здесь
|
||||
// код, а не схема: колонка допускает пустое значение ради записей бота.
|
||||
func TestCreateJobFromApiRequiresOwner(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", "")
|
||||
|
||||
require.ErrorIs(t, err, contract.ErrOwnerRequired)
|
||||
}
|
||||
|
||||
records, err := env.app.FindAllRecords("transcribe_jobs")
|
||||
// Чужая запись, ничья и несуществующая отвечают одним и тем же: по разнице
|
||||
// ответов иначе перебирается список заведённых записей.
|
||||
func TestGetByIDHidesForeignRecords(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
owner := newOwner(t, env.app)
|
||||
stranger := newOwner(t, env.app)
|
||||
|
||||
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, records, "задачи не заведено")
|
||||
|
||||
// Запись без владельца — принятая ботом.
|
||||
orphan := newTelegramRecord(t, env)
|
||||
|
||||
var missing *contract.JobNotFoundError
|
||||
|
||||
files, err := env.app.FindAllRecords("files")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, files, "и файла тоже: отказ наступает раньше укладки")
|
||||
_, err = env.recordRepo.GetByID(record.Id, stranger)
|
||||
require.ErrorAs(t, err, &missing, "чужая запись неотличима от несуществующей")
|
||||
|
||||
_, err = env.recordRepo.GetByID(orphan.Id, stranger)
|
||||
require.ErrorAs(t, err, &missing, "ничья запись не достаётся никому")
|
||||
|
||||
_, err = env.recordRepo.GetByID(record.Id, "")
|
||||
require.ErrorAs(t, err, &missing, "пустой владелец не совпадает ни с чем")
|
||||
|
||||
own, err := env.recordRepo.GetByID(record.Id, owner)
|
||||
require.NoError(t, err, "своя запись отдаётся")
|
||||
assert.Equal(t, record.Id, own.Id)
|
||||
}
|
||||
|
||||
+547
-210
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -24,10 +25,22 @@ import (
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Проверки конвейера идут против настоящего хранилища: захват, число попыток и
|
||||
// переход в «мертва» держатся на запросе, и подставной репозиторий проверял бы
|
||||
// Проверки конвейера идут против настоящего хранилища: захват, число отказов и
|
||||
// остановка держатся на запросе, и подставной репозиторий проверял бы
|
||||
// собственную заглушку, а не то, что делает база.
|
||||
|
||||
// okConverter переливает исходник в результат — так это выглядит у настоящего
|
||||
// приведения к рабочему формату.
|
||||
type okConverter struct{}
|
||||
|
||||
func (c *okConverter) Convert(_ context.Context, src, dest string) error {
|
||||
content, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dest, content, 0o600)
|
||||
}
|
||||
|
||||
// failingConverter отказывает на каждой попытке.
|
||||
type failingConverter struct{}
|
||||
|
||||
@@ -47,26 +60,50 @@ func (m *failingMetaViewer) GetInfo(context.Context, string) (*contract.AudioInf
|
||||
return nil, errors.New("запись не читается")
|
||||
}
|
||||
|
||||
// recordingSender запоминает, что и куда отправлено.
|
||||
// recordingSender запоминает, что отправлено.
|
||||
type recordingSender struct {
|
||||
mu sync.Mutex
|
||||
messages []string
|
||||
}
|
||||
|
||||
func (s *recordingSender) Send(text string, chatId int64, replyMsgId *int) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
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 (s *recordingSender) sent() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]string(nil), s.messages...)
|
||||
}
|
||||
|
||||
type pipelineEnv struct {
|
||||
app core.App
|
||||
service *TranscribeService
|
||||
repos Repositories
|
||||
recordRepo *pbrepo.AudioRecordRepository
|
||||
fileRepo *pbrepo.FileRepository
|
||||
sender *recordingSender
|
||||
}
|
||||
|
||||
// testLimits — пределы простоя проверок. Числа боевые; проверка застревания
|
||||
// двигает не их, а время входа записи в рубеж: сторож меряет именно его.
|
||||
var testLimits = entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour}
|
||||
|
||||
func newPipelineEnv(t *testing.T, metaviewer contract.AudioMetaViewer, converter contract.AudioFileConverter) *pipelineEnv {
|
||||
t.Helper()
|
||||
return newPipelineEnvWith(t, metaviewer, converter, &recognizer.MemoryAudioRecognizer{})
|
||||
}
|
||||
|
||||
func newPipelineEnvWith(
|
||||
t *testing.T,
|
||||
metaviewer contract.AudioMetaViewer,
|
||||
converter contract.AudioFileConverter,
|
||||
rec contract.AudioRecognizer,
|
||||
) *pipelineEnv {
|
||||
t.Helper()
|
||||
|
||||
app, err := pbrepo.New(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
@@ -81,158 +118,410 @@ func newPipelineEnv(t *testing.T, metaviewer contract.AudioMetaViewer, converter
|
||||
// нет.
|
||||
pbrepo.BindPanelRules(app)
|
||||
|
||||
jobRepo := pbrepo.NewTranscriptJobRepository(app)
|
||||
recordRepo := pbrepo.NewAudioRecordRepository(app)
|
||||
fileRepo := pbrepo.NewFileRepository(app)
|
||||
repos := Repositories{
|
||||
Records: recordRepo,
|
||||
Files: fileRepo,
|
||||
Texts: pbrepo.NewTextRepository(app),
|
||||
Structures: pbrepo.NewStructureRepository(app),
|
||||
Recognitions: pbrepo.NewRecognitionRepository(app),
|
||||
Events: pbrepo.NewRecordEventRepository(app),
|
||||
}
|
||||
sender := &recordingSender{}
|
||||
|
||||
svc := NewTranscribeService(
|
||||
jobRepo,
|
||||
fileRepo,
|
||||
metaviewer,
|
||||
converter,
|
||||
&recognizer.MemoryAudioRecognizer{},
|
||||
sender,
|
||||
slog.New(slog.DiscardHandler),
|
||||
)
|
||||
svc := NewTranscribeService(repos, metaviewer, converter, rec, sender, testLimits, slog.New(slog.DiscardHandler))
|
||||
|
||||
return &pipelineEnv{app: app, service: svc, jobRepo: jobRepo, fileRepo: fileRepo, sender: sender}
|
||||
return &pipelineEnv{
|
||||
app: app,
|
||||
service: svc,
|
||||
repos: repos,
|
||||
recordRepo: recordRepo,
|
||||
fileRepo: fileRepo,
|
||||
sender: sender,
|
||||
}
|
||||
}
|
||||
|
||||
// newTelegramJob заводит задачу с записью — так, как её завёл бы приём.
|
||||
func newTelegramJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob {
|
||||
// newTelegramRecord заводит запись — так, как её завёл бы приём из бота.
|
||||
func newTelegramRecord(t *testing.T, env *pipelineEnv) *entity.AudioRecord {
|
||||
t.Helper()
|
||||
|
||||
chatId := int64(100)
|
||||
job, err := env.service.CreateJobFromTelegram(t.Context(), strings.NewReader("запись"), "voice.ogg", chatId, 1)
|
||||
record, err := env.service.CreateJobFromTelegram(t.Context(), strings.NewReader("запись"), "voice.ogg", 100, 1)
|
||||
require.NoError(t, err)
|
||||
return job
|
||||
return record
|
||||
}
|
||||
|
||||
// clearDelay снимает паузу, чтобы следующий прогон взял задачу сразу: проверка
|
||||
// судит счётчик попыток, а не то, умеет ли она ждать.
|
||||
func clearDelay(t *testing.T, env *pipelineEnv, jobID string) {
|
||||
// clearDelay снимает паузу, чтобы следующий прогон взял запись сразу.
|
||||
func clearDelay(t *testing.T, env *pipelineEnv, recordID string) {
|
||||
t.Helper()
|
||||
|
||||
record, err := env.app.FindRecordById(migrations.JobsCollection, jobID)
|
||||
record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID)
|
||||
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) {
|
||||
// enteredStateAt отодвигает время входа записи в рубеж: так это выглядит, когда
|
||||
// запись простояла в нём дольше предела.
|
||||
func enteredStateAt(t *testing.T, env *pipelineEnv, recordID string, moment time.Time) {
|
||||
t.Helper()
|
||||
|
||||
record, err := env.app.FindRecordById(migrations.JobsCollection, jobID)
|
||||
record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID)
|
||||
require.NoError(t, err)
|
||||
record.Set("acquire_time", types.NowDateTime().Add(-24*time.Hour))
|
||||
record.Set("state_entered_at", types.DateTime{}.Add(0))
|
||||
stamp, err := types.ParseDateTime(moment)
|
||||
require.NoError(t, err)
|
||||
record.Set("state_entered_at", stamp)
|
||||
require.NoError(t, env.app.Save(record))
|
||||
}
|
||||
|
||||
// Задача, падающая на каждой попытке, уходит в «мертва»: из выборки исчезает,
|
||||
// видна отбором по состоянию, а отправитель узнаёт о неудаче. Инвариант
|
||||
// «Принятая запись не теряется молча» допускает два исхода, и молчаливая смерть
|
||||
// не подходит ни под один.
|
||||
func TestJobDiesAfterAttemptLimit(t *testing.T) {
|
||||
// drain крутит конвейер, пока он двигает записи. Паузы опроса снимаются: они
|
||||
// проверяются отдельно, а здесь мешают дойти до конца.
|
||||
func drain(t *testing.T, env *pipelineEnv, recordID string) {
|
||||
t.Helper()
|
||||
|
||||
for range 20 {
|
||||
clearDelay(t, env, recordID)
|
||||
err := env.service.RunStep(t.Context())
|
||||
var noop *contract.NoopJobError
|
||||
if errors.As(err, &noop) {
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
after := readRecord(t, env, recordID)
|
||||
if after.State == entity.StateDone || after.IsHalted() {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("конвейер не дошёл до исхода за отведённое число прогонов")
|
||||
}
|
||||
|
||||
// readRecord читает запись мимо сужения владельцем.
|
||||
//
|
||||
// Читающий метод сервиса отдаёт запись только её владельцу, а проверки конвейера
|
||||
// смотрят записи, принятые ботом: владельца у таких нет вовсе. Проверке нужно не
|
||||
// право, а состояние записи после шага.
|
||||
func readRecord(t *testing.T, env *pipelineEnv, id string) *entity.AudioRecord {
|
||||
t.Helper()
|
||||
|
||||
record, err := env.recordRepo.Get(id)
|
||||
require.NoError(t, err)
|
||||
return record
|
||||
}
|
||||
|
||||
// Запись, отказывающая на каждой попытке, останавливается признаком: из выборки
|
||||
// исчезает, рубеж сохраняет, а отправитель узнаёт о неудаче. Инвариант «Принятая
|
||||
// запись не теряется молча» допускает два исхода, и молчаливая остановка не
|
||||
// подходит ни под один.
|
||||
func TestRecordHaltsAfterAttemptLimit(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
job := newTelegramJob(t, env)
|
||||
record := newTelegramRecord(t, env)
|
||||
|
||||
// Отказ конвертации переводит задачу в `failed` сразу, поэтому предел
|
||||
// попыток проверяем на шаге, который отказывает *не* приговором: подменяем
|
||||
// его отказом источника метаданных внутри самого шага конвертации нельзя, и
|
||||
// вместо этого гоняем захват без выполнения шага — так же, как это выглядит
|
||||
// при гибели процесса.
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
_, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
|
||||
// Захват без выполнения шага — так это выглядит при гибели процесса: отказа
|
||||
// шаг объявить не успевает, а попытка засчитана.
|
||||
for range maxAttempts {
|
||||
_, err := env.recordRepo.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err)
|
||||
expireAcquisition(t, env, record.Id)
|
||||
}
|
||||
rotAcquisition(t, env, job.Id)
|
||||
|
||||
// Следующий захват видит перебор и хоронит задачу.
|
||||
err := env.service.FindAndRunConversionJob(t.Context())
|
||||
err := env.service.RunStep(t.Context())
|
||||
|
||||
var noop *contract.NoopJobError
|
||||
require.ErrorAs(t, err, &noop, "мёртвая задача шагу не отдаётся")
|
||||
require.ErrorAs(t, err, &noop, "остановленная запись шагу не отдаётся")
|
||||
|
||||
after, err := readJob(env.app, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateDead, after.State, "задача видна отбором по состоянию")
|
||||
assert.Greater(t, after.Attempts, maxAttempts, "число попыток сохранено")
|
||||
after := readRecord(t, env, record.Id)
|
||||
assert.True(t, after.IsHalted(), "запись остановлена признаком")
|
||||
require.NotNil(t, after.HaltReason)
|
||||
assert.Equal(t, entity.HaltReasonAttempts, *after.HaltReason)
|
||||
assert.Equal(t, entity.StateUploaded, after.State, "рубеж пережил остановку")
|
||||
assert.Greater(t, after.Attempts, maxAttempts, "число отказов сохранено")
|
||||
|
||||
require.Len(t, env.sender.messages, 1, "отправитель узнал о неудаче")
|
||||
assert.Contains(t, env.sender.messages[0], "попытки исчерпаны")
|
||||
require.Len(t, env.sender.sent(), 1, "отправитель узнал о неудаче")
|
||||
assert.Contains(t, env.sender.sent()[0], "попытки исчерпаны")
|
||||
|
||||
// И из выборки она исчезла.
|
||||
_, err = env.jobRepo.FindAndAcquire(entity.StateCreated, "next", time.Now().Add(time.Hour))
|
||||
_, err = env.recordRepo.FindAndAcquire(entity.WorkingStages())
|
||||
var missing *contract.JobNotFoundError
|
||||
assert.ErrorAs(t, err, &missing)
|
||||
}
|
||||
|
||||
// Мёртвая задача возвращается в работу правкой состояния.
|
||||
func TestDeadJobReturnsAfterStateEdit(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
// expireAcquisition отодвигает срок протухания захвата в прошлое: так это
|
||||
// выглядит, когда шаг оборвался вместе с процессом.
|
||||
func expireAcquisition(t *testing.T, env *pipelineEnv, recordID string) {
|
||||
t.Helper()
|
||||
|
||||
job := newTelegramJob(t, env)
|
||||
record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID)
|
||||
require.NoError(t, err)
|
||||
record.Set("acquire_expires_at", types.NowDateTime().Add(-time.Hour))
|
||||
require.NoError(t, env.app.Save(record))
|
||||
}
|
||||
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
_, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
|
||||
// Критерий приёмки 1. Остановленная на шаге запись перезапускается снятием
|
||||
// признака и продолжает с того рубежа, где стояла, — следующим идёт отправка на
|
||||
// распознавание, а не повторное приведение.
|
||||
func TestHaltedRecordResumesFromItsStage(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
|
||||
// Доводим до рубежа приведения и останавливаем на нём.
|
||||
require.NoError(t, env.service.RunStep(t.Context()))
|
||||
after := readRecord(t, env, record.Id)
|
||||
require.Equal(t, entity.StateNormalized, after.State)
|
||||
|
||||
after.Halt(entity.HaltReasonStepFailed, "проверочная остановка")
|
||||
require.NoError(t, env.recordRepo.Save(after, ""))
|
||||
|
||||
// Остановленная запись захвату не выдаётся.
|
||||
_, err := env.recordRepo.FindAndAcquire(entity.WorkingStages())
|
||||
var missing *contract.JobNotFoundError
|
||||
require.ErrorAs(t, err, &missing, "остановленная запись из выборки исчезла")
|
||||
|
||||
// Снятие признака возвращает её в работу с сохранённого рубежа.
|
||||
halted := readRecord(t, env, record.Id)
|
||||
halted.Resume()
|
||||
require.NoError(t, env.recordRepo.Save(halted, ""))
|
||||
|
||||
resumed := readRecord(t, env, record.Id)
|
||||
assert.Equal(t, entity.StateNormalized, resumed.State, "рубеж сохранён")
|
||||
assert.Equal(t, 0, resumed.Attempts, "отказы сброшены")
|
||||
|
||||
// Следующим идёт отправка на распознавание, а не повторное приведение.
|
||||
require.NoError(t, env.service.RunStep(t.Context()))
|
||||
next := readRecord(t, env, record.Id)
|
||||
assert.Equal(t, entity.StateSubmitted, next.State, "продолжили с места остановки")
|
||||
assert.NotNil(t, next.RecognitionID, "отправка состоялась")
|
||||
}
|
||||
|
||||
// Критерий приёмки 2. У прошедшей конвейер записи ссылки на исходник и на
|
||||
// приведённую копию ведут на разные существующие файлы: прежде ссылка была одна,
|
||||
// и каждый шаг переставлял её на свой результат.
|
||||
func TestBothFileLinksSurvivePipeline(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
drain(t, env, record.Id)
|
||||
|
||||
after := readRecord(t, env, record.Id)
|
||||
require.Equal(t, entity.StateDone, after.State)
|
||||
|
||||
require.NotNil(t, after.OriginalFileID, "ссылка на принятую копию заполнена")
|
||||
require.NotNil(t, after.NormalizedFileID, "ссылка на приведённую копию заполнена")
|
||||
assert.NotEqual(t, *after.OriginalFileID, *after.NormalizedFileID, "копии разные")
|
||||
|
||||
for _, id := range []string{*after.OriginalFileID, *after.NormalizedFileID} {
|
||||
reader, err := env.fileRepo.Open(id)
|
||||
require.NoError(t, err, "обе копии открываются")
|
||||
content, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, content)
|
||||
require.NoError(t, reader.Close())
|
||||
}
|
||||
require.Error(t, env.service.FindAndRunConversionJob(t.Context()))
|
||||
|
||||
record, err := env.app.FindRecordById(migrations.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{})
|
||||
// Критерий приёмки 3. Поведение записи не зависит от числа воркеров: при одном
|
||||
// и при нескольких она доходит до конечного рубежа.
|
||||
func TestOutcomeDoesNotDependOnWorkerCount(t *testing.T) {
|
||||
for _, workers := range []int{1, 4} {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||
record := newTelegramRecord(t, env)
|
||||
|
||||
job := newTelegramJob(t, env)
|
||||
for range 20 {
|
||||
clearDelay(t, env, record.Id)
|
||||
|
||||
// Ссылку переставляем на запись без содержимого: шаг отказывает на получении
|
||||
// рабочей копии — то есть отказом, а не приговором записи.
|
||||
empty, err := env.fileRepo.CreateRemote("object-key", 1, "")
|
||||
require.NoError(t, err)
|
||||
var wg sync.WaitGroup
|
||||
for range workers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// Исход прогона здесь не судится: воркеров несколько, и всем,
|
||||
// кроме одного, работы не достаётся. Судится состояние записи.
|
||||
if err := env.service.RunStep(t.Context()); err != nil {
|
||||
t.Log(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
|
||||
require.NoError(t, err)
|
||||
record.Set("file", empty.Id)
|
||||
require.NoError(t, env.app.Save(record))
|
||||
if readRecord(t, env, record.Id).State == entity.StateDone {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Первый отказ.
|
||||
require.Error(t, env.service.FindAndRunConversionJob(t.Context()))
|
||||
|
||||
after, err := readJob(env.app, 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(t.Context()))
|
||||
|
||||
after, err = readJob(env.app, job.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, after.DelayTime)
|
||||
|
||||
secondDelay := time.Until(*after.DelayTime)
|
||||
assert.Greater(t, secondDelay, firstDelay, "вторая пауза длиннее первой")
|
||||
after := readRecord(t, env, record.Id)
|
||||
assert.Equalf(t, entity.StateDone, after.State, "запись дошла до конца при %d воркерах", workers)
|
||||
assert.Falsef(t, after.IsHalted(), "запись не остановлена при %d воркерах", workers)
|
||||
}
|
||||
}
|
||||
|
||||
// Пауза растёт с числом попыток и упирается в потолок.
|
||||
// Тот же критерий, вторая половина: нулевое число воркеров — законное значение.
|
||||
// Записи принимаются и не двигаются, и это режим, а не поломка.
|
||||
func TestZeroWorkersLeaveRecordUntouched(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||
record := newTelegramRecord(t, env)
|
||||
|
||||
// Пул нулевого размера ни одного прогона не делает — потому запись остаётся
|
||||
// там, где её оставил приём.
|
||||
after := readRecord(t, env, record.Id)
|
||||
assert.Equal(t, entity.StateUploaded, after.State, "запись принята и стоит на первом рубеже")
|
||||
assert.False(t, after.IsHalted(), "и не потеряна")
|
||||
assert.Equal(t, record.Id, after.Id)
|
||||
}
|
||||
|
||||
// Критерий приёмки 5, первая половина. Откладывание опроса не двигает время
|
||||
// входа в рубеж и не обнуляет отсчёт застревания: иначе запись, чью чужую
|
||||
// операцию опрашивают раз в несколько секунд, не достигла бы предела никогда.
|
||||
func TestPostponeKeepsStuckCountdown(t *testing.T) {
|
||||
entered := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
|
||||
record := &entity.AudioRecord{State: entity.StateSubmitted, StateEnteredAt: entered}
|
||||
|
||||
for range 100 {
|
||||
record.Postpone(time.Now().Add(5 * time.Second))
|
||||
}
|
||||
|
||||
assert.Equal(t, entered, record.StateEnteredAt, "сотня откладываний не двигает отсчёт")
|
||||
assert.Equal(t, entity.StateSubmitted, record.State, "и рубежа не трогает")
|
||||
assert.Nil(t, record.AcquisitionID, "захват при этом снят")
|
||||
assert.NotNil(t, record.DelayTime, "а пауза поставлена")
|
||||
}
|
||||
|
||||
// Критерий приёмки 5, вторая половина. Запись, простоявшая в рубеже дольше
|
||||
// предела, останавливается признаком с причиной «застряла».
|
||||
func TestStuckRecordIsHalted(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
enteredStateAt(t, env, record.Id, time.Now().Add(-2*testLimits.Own))
|
||||
|
||||
err := env.service.RunStep(t.Context())
|
||||
var noop *contract.NoopJobError
|
||||
require.ErrorAs(t, err, &noop, "застрявшая запись шагу не отдаётся")
|
||||
|
||||
after := readRecord(t, env, record.Id)
|
||||
require.True(t, after.IsHalted())
|
||||
require.NotNil(t, after.HaltReason)
|
||||
assert.Equal(t, entity.HaltReasonStuck, *after.HaltReason, "причина названа")
|
||||
assert.Equal(t, entity.StateUploaded, after.State, "рубеж сохранён")
|
||||
|
||||
require.Len(t, env.sender.sent(), 1, "отправитель узнал о неудаче")
|
||||
assert.Contains(t, env.sender.sent()[0], "застряла")
|
||||
}
|
||||
|
||||
// Конечный рубеж под предел простоя не подпадает: стоять в нём запись будет
|
||||
// вечно по построению, и сторож остановил бы всякую доведённую запись.
|
||||
func TestDoneRecordIsNeverStuck(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
drain(t, env, record.Id)
|
||||
enteredStateAt(t, env, record.Id, time.Now().Add(-10*24*time.Hour))
|
||||
|
||||
err := env.service.RunStep(t.Context())
|
||||
var noop *contract.NoopJobError
|
||||
require.ErrorAs(t, err, &noop, "доведённая запись в работу не берётся")
|
||||
|
||||
after := readRecord(t, env, record.Id)
|
||||
assert.Equal(t, entity.StateDone, after.State)
|
||||
assert.False(t, after.IsHalted(), "признака остановки у доведённой записи нет")
|
||||
}
|
||||
|
||||
// Критерий приёмки 6. Держатель захвата отличим значением, а не занятостью
|
||||
// записи: шаг, чей захват достался другому, результата не пишет и отправителю
|
||||
// ничего не шлёт.
|
||||
func TestOnlyHolderWritesResult(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
|
||||
first, err := env.recordRepo.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record.Id, first.ID)
|
||||
|
||||
// Человек снял признак остановки — панель чистит признак захвата, — и запись
|
||||
// достаётся другому воркеру.
|
||||
expireAcquisition(t, env, record.Id)
|
||||
second, err := env.recordRepo.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, first.Holder, second.Holder, "признак нового захвата отличается")
|
||||
|
||||
// Первый доходит до записи результата и получает отказ.
|
||||
stale := readRecord(t, env, record.Id)
|
||||
stale.MoveToState(entity.StateNormalized)
|
||||
err = env.recordRepo.Save(stale, first.Holder)
|
||||
|
||||
var lost *contract.LostAcquisitionError
|
||||
require.ErrorAs(t, err, &lost, "потерявший захват не пишет")
|
||||
|
||||
after := readRecord(t, env, record.Id)
|
||||
assert.Equal(t, entity.StateUploaded, after.State, "рубеж не сдвинут потерявшим захват")
|
||||
assert.Empty(t, env.sender.sent(), "и отправителю от него ничего не ушло")
|
||||
}
|
||||
|
||||
// Критерий приёмки 7. Всякий способ вывести запись из работы сообщает
|
||||
// отправителю: причин остановки больше одной, и обязанность у них общая.
|
||||
func TestEveryHaltReasonNotifiesSender(t *testing.T) {
|
||||
reasons := []struct {
|
||||
name string
|
||||
halt func(t *testing.T, env *pipelineEnv, recordID string)
|
||||
}{
|
||||
{
|
||||
name: "приговор шага",
|
||||
halt: func(t *testing.T, env *pipelineEnv, recordID string) {
|
||||
require.NoError(t, env.service.RunStep(t.Context()))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "застревание",
|
||||
halt: func(t *testing.T, env *pipelineEnv, recordID string) {
|
||||
enteredStateAt(t, env, recordID, time.Now().Add(-2*testLimits.Own))
|
||||
var noop *contract.NoopJobError
|
||||
require.ErrorAs(t, env.service.RunStep(t.Context()), &noop)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, reason := range reasons {
|
||||
t.Run(reason.name, func(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
record := newTelegramRecord(t, env)
|
||||
|
||||
reason.halt(t, env, record.Id)
|
||||
|
||||
after := readRecord(t, env, record.Id)
|
||||
require.True(t, after.IsHalted(), "запись остановлена")
|
||||
require.Len(t, env.sender.sent(), 1, "отправитель узнал о неудаче")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Критерий приёмки 8. Повтор шага не создаёт второго приложения: пара «запись и
|
||||
// вид» уникальна, и вопрос «какой текст отдавать человеку» не становится
|
||||
// вопросом порядка записи.
|
||||
func TestRepeatedStoreKeepsSingleText(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
|
||||
first, err := env.repos.Texts.Put(record.Id, entity.TextKindTranscript, "первый разбор")
|
||||
require.NoError(t, err)
|
||||
second, err := env.repos.Texts.Put(record.Id, entity.TextKindTranscript, "второй разбор")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, first.Id, second.Id, "строка та же, а не вторая")
|
||||
|
||||
stored, err := env.repos.Texts.GetByID(first.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "второй разбор", stored.Contents)
|
||||
|
||||
count, err := env.app.CountRecords(migrations.TextsCollection)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), count, "второго комплекта строк не завелось")
|
||||
}
|
||||
|
||||
// Пауза растёт с числом отказов и упирается в потолок.
|
||||
func TestRetryDelayGrowsAndCaps(t *testing.T) {
|
||||
assert.Equal(t, retryDelayBase, retryDelay(1))
|
||||
assert.Equal(t, 2*retryDelayBase, retryDelay(2))
|
||||
@@ -241,6 +530,42 @@ func TestRetryDelayGrowsAndCaps(t *testing.T) {
|
||||
assert.Equal(t, retryDelayBase, retryDelay(0), "нулевая попытка не даёт нулевой паузы")
|
||||
}
|
||||
|
||||
// Отказ шага не оставляет запись захваченной до конца срока: захват снимается,
|
||||
// и запись ждёт нарастающую паузу. Иначе повтор наступал бы через часы.
|
||||
func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
|
||||
// Ссылка переставляется на запись о файле без содержимого: шаг отказывает на
|
||||
// получении рабочей копии — то есть отказом, а не приговором записи.
|
||||
files, err := env.app.FindCollectionByNameOrId(migrations.FilesCollection)
|
||||
require.NoError(t, err)
|
||||
empty := core.NewRecord(files)
|
||||
empty.Set("location", entity.LocationLocal)
|
||||
empty.Set("size", 1)
|
||||
require.NoError(t, env.app.Save(empty))
|
||||
|
||||
stored, err := env.app.FindRecordById(migrations.RecordsCollection, record.Id)
|
||||
require.NoError(t, err)
|
||||
stored.Set("original_file", empty.Id)
|
||||
require.NoError(t, env.app.Save(stored))
|
||||
|
||||
require.Error(t, env.service.RunStep(t.Context()))
|
||||
|
||||
after := readRecord(t, env, record.Id)
|
||||
require.Nil(t, after.AcquisitionID, "захват снят: запись пригодна к повтору")
|
||||
require.NotNil(t, after.DelayTime, "пауза поставлена")
|
||||
firstDelay := time.Until(*after.DelayTime)
|
||||
|
||||
clearDelay(t, env, record.Id)
|
||||
require.Error(t, env.service.RunStep(t.Context()))
|
||||
|
||||
after = readRecord(t, env, record.Id)
|
||||
require.NotNil(t, after.DelayTime)
|
||||
assert.Greater(t, time.Until(*after.DelayTime), firstDelay, "вторая пауза длиннее первой")
|
||||
}
|
||||
|
||||
// Рабочая копия убирается на любом исходе, включая отказ. Забытая копия — это
|
||||
// шестичасовая запись во временном каталоге, и узнать о ней неоткуда.
|
||||
func TestWorkFileRemovedAfterIntakeFailure(t *testing.T) {
|
||||
@@ -273,24 +598,43 @@ func TestWorkFileRemovedAfterSuccessfulIntake(t *testing.T) {
|
||||
assert.Empty(t, leftovers, "рабочей копии после успеха не остаётся")
|
||||
}
|
||||
|
||||
// Задача не остаётся ссылающейся на файл, которого нет: ссылка переставляется
|
||||
// только после того, как запись о новом файле существует.
|
||||
func TestJobNeverPointsToMissingFile(t *testing.T) {
|
||||
// Уборка проверяется и на шаге приведения: репозиторий даёт единственный способ
|
||||
// убрать копию, но зовёт его шаг. Копий здесь две — исходник и результат.
|
||||
func TestWorkFilesRemovedAfterConversionFailure(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
job := newTelegramJob(t, env)
|
||||
newTelegramRecord(t, env)
|
||||
|
||||
// Конвертация отказывает — задача уходит в `failed`, но ссылка остаётся на
|
||||
// исходную запись, а не на несозданный результат.
|
||||
require.NoError(t, env.service.FindAndRunConversionJob(t.Context()))
|
||||
|
||||
after, err := readJob(env.app, job.Id)
|
||||
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateFailed, after.State)
|
||||
require.NotNil(t, after.FileID)
|
||||
require.Empty(t, leftovers, "приём убрал свою рабочую копию")
|
||||
|
||||
file, err := env.fileRepo.GetByID(*after.FileID)
|
||||
require.NoError(t, err, "ссылка задачи ведёт на существующую запись о файле")
|
||||
require.NoError(t, env.service.RunStep(t.Context()))
|
||||
|
||||
leftovers, err = filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, leftovers, "ни исходной копии, ни копии под результат не осталось")
|
||||
}
|
||||
|
||||
// Запись не остаётся ссылающейся на файл, которого нет: ссылка ставится только
|
||||
// после того, как запись о файле существует.
|
||||
func TestRecordNeverPointsToMissingFile(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
|
||||
require.NoError(t, env.service.RunStep(t.Context()))
|
||||
|
||||
after := readRecord(t, env, record.Id)
|
||||
assert.True(t, after.IsHalted(), "приговор шага остановил запись")
|
||||
require.NotNil(t, after.OriginalFileID)
|
||||
assert.Nil(t, after.NormalizedFileID, "ссылки на несозданный результат не появилось")
|
||||
|
||||
file, err := env.fileRepo.GetByID(*after.OriginalFileID)
|
||||
require.NoError(t, err, "ссылка ведёт на существующую запись о файле")
|
||||
assert.NotEmpty(t, file.FileName)
|
||||
}
|
||||
|
||||
@@ -300,11 +644,11 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
|
||||
|
||||
content := strings.Repeat("запись ", 1000)
|
||||
|
||||
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader(content), "sample.mp3", newOwner(t, env.app))
|
||||
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader(content), "sample.mp3", newOwner(t, env.app))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, job.FileID)
|
||||
require.NotNil(t, record.OriginalFileID)
|
||||
|
||||
reader, err := env.fileRepo.Open(*job.FileID)
|
||||
reader, err := env.fileRepo.Open(*record.OriginalFileID)
|
||||
require.NoError(t, err)
|
||||
defer reader.Close()
|
||||
|
||||
@@ -312,22 +656,22 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, content, string(stored))
|
||||
|
||||
// И длина в учёте совпадает с длиной принятого.
|
||||
file, err := env.fileRepo.GetByID(*job.FileID)
|
||||
file, err := env.fileRepo.GetByID(*record.OriginalFileID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(len(content)), file.Size)
|
||||
assert.Equal(t, "mp3", file.Format, "формат копии записан")
|
||||
}
|
||||
|
||||
// Рабочая копия хранимого файла отдаётся именем на диске — так её получают
|
||||
// шаги, отдающие файл внешней программе.
|
||||
// Рабочая копия хранимого файла отдаётся именем на диске — так её получают шаги,
|
||||
// отдающие файл внешней программе.
|
||||
func TestLocalizeGivesReadableCopy(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("содержимое"), "sample.mp3", newOwner(t, env.app))
|
||||
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("содержимое"), "sample.mp3", newOwner(t, env.app))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, job.FileID)
|
||||
require.NotNil(t, record.OriginalFileID)
|
||||
|
||||
work, err := env.fileRepo.Localize(*job.FileID)
|
||||
work, err := env.fileRepo.Localize(*record.OriginalFileID)
|
||||
require.NoError(t, err)
|
||||
|
||||
content, err := os.ReadFile(work.Path())
|
||||
@@ -339,86 +683,10 @@ func TestLocalizeGivesReadableCopy(t *testing.T) {
|
||||
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(t.Context()))
|
||||
|
||||
leftovers, err = filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, leftovers, "ни исходной копии, ни копии под результат не осталось")
|
||||
}
|
||||
|
||||
// readJob читает задачу мимо сужения владельцем.
|
||||
//
|
||||
// Читающий метод хранилища отдаёт задачу только её владельцу, а проверки
|
||||
// конвейера смотрят задачи, принятые ботом: владельца у таких нет вовсе, и по
|
||||
// правилу разграничения они не достаются никому. Проверке нужен не доступ, а
|
||||
// состояние записи после шага, поэтому она берёт его прямо из хранилища. Это
|
||||
// не второй способ читать задачу в сервисе — в сервисе способ по-прежнему один.
|
||||
func readJob(app core.App, id string) (*entity.TranscribeJob, error) {
|
||||
record, err := app.FindRecordById(migrations.JobsCollection, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
job := &entity.TranscribeJob{
|
||||
Id: record.Id,
|
||||
State: record.GetString("state"),
|
||||
Source: record.GetString("source"),
|
||||
Attempts: record.GetInt("attempts"),
|
||||
CreatedAt: record.GetDateTime("created").Time(),
|
||||
UpdatedAt: record.GetDateTime("updated").Time(),
|
||||
}
|
||||
|
||||
for _, field := range []struct {
|
||||
name string
|
||||
dst **string
|
||||
}{
|
||||
{"owner", &job.OwnerID},
|
||||
{"file", &job.FileID},
|
||||
{"error_text", &job.ErrorText},
|
||||
{"acquisition_id", &job.AcquisitionID},
|
||||
{"recognition_op_id", &job.RecognitionOpID},
|
||||
{"transcription_text", &job.TranscriptionText},
|
||||
} {
|
||||
if value := record.GetString(field.name); value != "" {
|
||||
stored := value
|
||||
*field.dst = &stored
|
||||
}
|
||||
}
|
||||
|
||||
if delay := record.GetDateTime("delay_time"); !delay.IsZero() {
|
||||
moment := delay.Time()
|
||||
job.DelayTime = &moment
|
||||
}
|
||||
if acquired := record.GetDateTime("acquire_time"); !acquired.IsZero() {
|
||||
moment := acquired.Time()
|
||||
job.AcquireTime = &moment
|
||||
}
|
||||
|
||||
return job, nil
|
||||
}
|
||||
|
||||
// newOwner заводит учётную запись и отдаёт её идентификатор.
|
||||
//
|
||||
// Владелец — связь с коллекцией пользователей, и хранилище проверяет, что такая
|
||||
// запись есть: выдуманный идентификатор задачу завести не даст.
|
||||
// запись есть: выдуманный идентификатор запись завести не даст.
|
||||
func newOwner(t *testing.T, app core.App) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -428,8 +696,77 @@ func newOwner(t *testing.T, app core.App) string {
|
||||
record := core.NewRecord(users)
|
||||
record.Set("email", uuid.NewString()+"@example.test")
|
||||
record.Set("verified", true)
|
||||
record.SetPassword(uuid.NewString())
|
||||
record.Set("password", uuid.NewString())
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
return record.Id
|
||||
}
|
||||
|
||||
// Остановка приговором шага засчитывается **отказом**, а не успехом, и пишет в
|
||||
// журнал записи одну строку, а не две.
|
||||
//
|
||||
// Пока исход шага выводился из «шаг не вернул ошибку», остановленная запись
|
||||
// получала событием `done` вслед за `halted` и растила счётчик успехов — то есть
|
||||
// единственный канал владельца молчал ровно там, где запись встала.
|
||||
func TestHaltIsCountedAsFailureAndLoggedOnce(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
beforeOk := stageCount(t, entity.StateUploaded, "false")
|
||||
beforeErr := stageCount(t, entity.StateUploaded, "true")
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
require.NoError(t, env.service.RunStep(t.Context()))
|
||||
|
||||
after := readRecord(t, env, record.Id)
|
||||
require.True(t, after.IsHalted(), "приговор шага остановил запись")
|
||||
|
||||
assert.InDelta(t, beforeErr+1, stageCount(t, entity.StateUploaded, "true"), 0,
|
||||
"остановка засчитана отказом")
|
||||
assert.InDelta(t, beforeOk, stageCount(t, entity.StateUploaded, "false"), 0,
|
||||
"и успехом не засчитана")
|
||||
|
||||
events := recordEvents(t, env, record.Id)
|
||||
require.Len(t, events, 1, "одна строка журнала, а не две")
|
||||
assert.Equal(t, entity.EventOutcomeFailed, events[0].GetString("outcome"),
|
||||
"исход назван приговором, а не сделанной работой")
|
||||
}
|
||||
|
||||
// Откладывание опроса в журнал событий не пишется: часовое ожидание чужой
|
||||
// операции дало бы там сотни строк ни о чём, и журнал, заведённый для человека,
|
||||
// стал бы нечитаемым ровно у долгой записи.
|
||||
func TestPostponeWritesNoEvent(t *testing.T) {
|
||||
rec := &countingRecognizer{}
|
||||
rec.inProgress.Store(5)
|
||||
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
require.NoError(t, env.service.RunStep(t.Context())) // приведение
|
||||
require.NoError(t, env.service.RunStep(t.Context())) // отправка
|
||||
require.Equal(t, entity.StateSubmitted, readRecord(t, env, record.Id).State)
|
||||
|
||||
before := len(recordEvents(t, env, record.Id))
|
||||
|
||||
for range 5 {
|
||||
clearDelay(t, env, record.Id)
|
||||
require.NoError(t, env.service.RunStep(t.Context()))
|
||||
}
|
||||
|
||||
assert.Len(t, recordEvents(t, env, record.Id), before,
|
||||
"пять откладываний не оставили в журнале ни строки")
|
||||
}
|
||||
|
||||
// recordEvents читает журнал событий одной записи в порядке заведения.
|
||||
func recordEvents(t *testing.T, env *pipelineEnv, recordID string) []*core.Record {
|
||||
t.Helper()
|
||||
|
||||
all, err := env.app.FindAllRecords(migrations.RecordEventsCollection)
|
||||
require.NoError(t, err)
|
||||
|
||||
var own []*core.Record
|
||||
for _, event := range all {
|
||||
if event.GetString("record") == recordID {
|
||||
own = append(own, event)
|
||||
}
|
||||
}
|
||||
return own
|
||||
}
|
||||
|
||||
@@ -2,267 +2,196 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"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/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Шаги распознавания переписаны переездом на новое хранилище целиком: они берут
|
||||
// содержимое по записи, заводят запись о копии во внешнем хранилище и пишут
|
||||
// результат условием по держателю захвата. Подставной распознаватель проекта
|
||||
// умеет только «завершено с фиксированным текстом», поэтому ветки ожидания,
|
||||
// отказа операции и пустого текста изобразить нечем — для них нужен управляемый
|
||||
// двойник.
|
||||
|
||||
// scriptedRecognizer отдаёт заданный исход проверки операции и заданный текст.
|
||||
type scriptedRecognizer struct {
|
||||
result *entity.RecognitionResult
|
||||
text string
|
||||
recognizeErr error
|
||||
|
||||
recognizeCalls int
|
||||
lastObjectKey string
|
||||
// countingRecognizer считает обращения наружу: за них платят по факту, и повтор
|
||||
// оплаченного шага — самый дорогой класс дефекта в этом сервисе.
|
||||
type countingRecognizer struct {
|
||||
uploads atomic.Int64
|
||||
submits atomic.Int64
|
||||
fetches atomic.Int64
|
||||
parses atomic.Int64
|
||||
objectHere atomic.Bool
|
||||
inProgress atomic.Int64
|
||||
}
|
||||
|
||||
func (r *scriptedRecognizer) Recognize(_ context.Context, file io.Reader, fileName string) (string, error) {
|
||||
r.recognizeCalls++
|
||||
r.lastObjectKey = fileName
|
||||
if r.recognizeErr != nil {
|
||||
return "", r.recognizeErr
|
||||
func (r *countingRecognizer) Provider() string { return "counting" }
|
||||
|
||||
func (r *countingRecognizer) Model() string { return "counting" }
|
||||
|
||||
func (r *countingRecognizer) Upload(_ context.Context, _ io.Reader, objectKey string) (string, error) {
|
||||
r.uploads.Add(1)
|
||||
r.objectHere.Store(true)
|
||||
return "counting://" + objectKey, nil
|
||||
}
|
||||
|
||||
func (r *countingRecognizer) ObjectExists(context.Context, string, int64) (bool, error) {
|
||||
return r.objectHere.Load(), nil
|
||||
}
|
||||
|
||||
func (r *countingRecognizer) SourceURI(objectKey string) string {
|
||||
return "counting://" + objectKey
|
||||
}
|
||||
|
||||
func (r *countingRecognizer) Submit(context.Context, string) (string, error) {
|
||||
r.submits.Add(1)
|
||||
return uuid.NewString(), nil
|
||||
}
|
||||
|
||||
func (r *countingRecognizer) CheckStatus(context.Context, string) (*entity.RecognitionResult, error) {
|
||||
if r.inProgress.Load() > 0 {
|
||||
r.inProgress.Add(-1)
|
||||
return entity.NewInProgressResult(), nil
|
||||
}
|
||||
// Содержимое обязано быть читаемым: шаг отдаёт его наружу потоком.
|
||||
if _, err := io.Copy(io.Discard, file); err != nil {
|
||||
return "", err
|
||||
return entity.NewCompletedResult(), nil
|
||||
}
|
||||
|
||||
func (r *countingRecognizer) Fetch(context.Context, string) (*entity.RecognitionOutcome, error) {
|
||||
r.fetches.Add(1)
|
||||
return r.Parse(countingPayload(t0Replicas))
|
||||
}
|
||||
|
||||
func (r *countingRecognizer) Parse(raw []byte) (*entity.RecognitionOutcome, error) {
|
||||
r.parses.Add(1)
|
||||
|
||||
var replicas []entity.Replica
|
||||
if err := json.Unmarshal(raw, &replicas); err != nil {
|
||||
return nil, errors.New("не разобрать сохранённый ответ")
|
||||
}
|
||||
return "operation-id", nil
|
||||
|
||||
var plain []byte
|
||||
for _, replica := range replicas {
|
||||
if len(plain) > 0 {
|
||||
plain = append(plain, ' ')
|
||||
}
|
||||
plain = append(plain, replica.Text...)
|
||||
}
|
||||
|
||||
return &entity.RecognitionOutcome{Replicas: replicas, PlainText: string(plain), Raw: raw}, nil
|
||||
}
|
||||
|
||||
func (r *scriptedRecognizer) GetRecognitionText(context.Context, string) (string, error) {
|
||||
return r.text, nil
|
||||
var t0Replicas = []entity.Replica{
|
||||
{StartMs: 0, EndMs: 900, Text: "Первая реплика."},
|
||||
{StartMs: 900, EndMs: 1800, Text: "Вторая реплика."},
|
||||
}
|
||||
|
||||
func (r *scriptedRecognizer) CheckRecognitionStatus(context.Context, string) (*entity.RecognitionResult, error) {
|
||||
return r.result, nil
|
||||
func countingPayload(replicas []entity.Replica) []byte {
|
||||
raw, err := json.Marshal(replicas)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// convertedJob доводит задачу до состояния, с которого работает шаг
|
||||
// распознавания: запись принята и сконвертирована.
|
||||
func convertedJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob {
|
||||
t.Helper()
|
||||
// Критерий приёмки 4. Структура реплик строится из сохранённого ответа
|
||||
// провайдера без единого обращения к нему: результат операции не
|
||||
// переспрашивается, и архив пересчитывается из сохранённого без рубля.
|
||||
func TestStructureIsBuiltFromStoredPayload(t *testing.T) {
|
||||
rec := &countingRecognizer{}
|
||||
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
|
||||
|
||||
job := newTelegramJob(t, env)
|
||||
record := newTelegramRecord(t, env)
|
||||
drain(t, env, record.Id)
|
||||
|
||||
acquired, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "setup", time.Now().Add(-time.Hour))
|
||||
after := readRecord(t, env, record.Id)
|
||||
require.Equal(t, entity.StateDone, after.State)
|
||||
require.NotNil(t, after.RecognitionID)
|
||||
|
||||
// Сохранённый ответ на месте и читается целиком.
|
||||
raw, err := env.repos.Recognitions.ReadRaw(*after.RecognitionID)
|
||||
require.NoError(t, err)
|
||||
acquired.MoveToState(entity.StateConverted)
|
||||
require.NoError(t, env.jobRepo.Save(acquired, "setup"))
|
||||
require.NotEmpty(t, raw, "сырой ответ провайдера сохранён")
|
||||
|
||||
return job
|
||||
}
|
||||
// А теперь — построение структуры из сохранённого, без обращений наружу.
|
||||
fetchesBefore := rec.fetches.Load()
|
||||
submitsBefore := rec.submits.Load()
|
||||
|
||||
// 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(t.Context()))
|
||||
|
||||
assert.Equal(t, 1, rec.recognizeCalls, "содержимое отдано распознавателю")
|
||||
assert.NotEmpty(t, rec.lastObjectKey, "ключ объекта назван")
|
||||
|
||||
after, err := readJob(env.app, job.Id)
|
||||
outcome, err := env.service.recognizer.Parse(raw)
|
||||
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.Len(t, outcome.Replicas, len(t0Replicas), "структура собрана")
|
||||
assert.Equal(t, t0Replicas[0].Text, outcome.Replicas[0].Text)
|
||||
assert.Equal(t, t0Replicas[0].StartMs, outcome.Replicas[0].StartMs, "время реплики сохранено")
|
||||
|
||||
// Ссылка задачи ведёт на существующую запись о копии, а не на несозданную.
|
||||
require.NotNil(t, after.FileID)
|
||||
copyRecord, err := env.fileRepo.GetByID(*after.FileID)
|
||||
assert.Equal(t, fetchesBefore, rec.fetches.Load(), "к провайдеру за результатом не ходили")
|
||||
assert.Equal(t, submitsBefore, rec.submits.Load(), "и новой операции не заводили")
|
||||
|
||||
// Структура записи собрана из того же ответа и лежит своей строкой.
|
||||
require.NotNil(t, after.StructureID)
|
||||
structure, err := env.repos.Structures.GetByID(*after.StructureID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.LocationS3, copyRecord.Location)
|
||||
assert.Equal(t, entity.StructureVersion, structure.Version)
|
||||
require.Len(t, structure.Replicas, len(t0Replicas))
|
||||
assert.Equal(t, t0Replicas[1].Text, structure.Replicas[1].Text)
|
||||
}
|
||||
|
||||
// Отказ распознавателя не двигает задачу: она остаётся пригодной к повтору.
|
||||
func TestTranscribeJobKeepsJobRetryableOnRecognizerFailure(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
job := convertedJob(t, env)
|
||||
// Шаг с внешней оплатой проверяет сделанное: объект нужного размера на месте —
|
||||
// заливку не повторяем; операция заведена — не платим второй раз.
|
||||
func TestPaidWorkIsNotRepeated(t *testing.T) {
|
||||
rec := &countingRecognizer{}
|
||||
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
|
||||
|
||||
rec := &scriptedRecognizer{recognizeErr: errors.New("распознаватель недоступен")}
|
||||
svc := withRecognizer(env, rec)
|
||||
record := newTelegramRecord(t, env)
|
||||
|
||||
require.Error(t, svc.FindAndRunTranscribeJob(t.Context()))
|
||||
// Приведение.
|
||||
require.NoError(t, env.service.RunStep(t.Context()))
|
||||
require.Equal(t, entity.StateNormalized, readRecord(t, env, record.Id).State)
|
||||
|
||||
after, err := readJob(env.app, 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, "отправителю про повторимый отказ не пишут")
|
||||
// Отправка.
|
||||
require.NoError(t, env.service.RunStep(t.Context()))
|
||||
require.Equal(t, int64(1), rec.uploads.Load(), "залили один раз")
|
||||
require.Equal(t, int64(1), rec.submits.Load(), "и заплатили один раз")
|
||||
|
||||
// Возвращаем запись на рубеж отправки — так это выглядит, когда шаг оборвался
|
||||
// после оплаты, а захват протух.
|
||||
after := readRecord(t, env, record.Id)
|
||||
after.MoveToState(entity.StateNormalized)
|
||||
require.NoError(t, env.recordRepo.Save(after, ""))
|
||||
|
||||
require.NoError(t, env.service.RunStep(t.Context()))
|
||||
|
||||
assert.Equal(t, int64(1), rec.uploads.Load(), "объект на месте — заливка не повторилась")
|
||||
assert.Equal(t, int64(1), rec.submits.Load(), "операция заведена — второй раз не платим")
|
||||
}
|
||||
|
||||
// transcribingJob доводит задачу до состояния ожидания операции.
|
||||
func transcribingJob(t *testing.T, env *pipelineEnv, rec contract.AudioRecognizer) *entity.TranscribeJob {
|
||||
t.Helper()
|
||||
// Ожидание чужой операции откладывается своей задержкой, отказов не тратит и
|
||||
// рубежа не двигает.
|
||||
func TestPollingPostponesWithoutSpendingAttempts(t *testing.T) {
|
||||
rec := &countingRecognizer{}
|
||||
rec.inProgress.Store(3)
|
||||
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
|
||||
|
||||
job := convertedJob(t, env)
|
||||
require.NoError(t, withRecognizer(env, rec).FindAndRunTranscribeJob(t.Context()))
|
||||
record := newTelegramRecord(t, env)
|
||||
|
||||
clearDelay(t, env, job.Id)
|
||||
return job
|
||||
}
|
||||
require.NoError(t, env.service.RunStep(t.Context())) // приведение
|
||||
require.NoError(t, env.service.RunStep(t.Context())) // отправка
|
||||
require.Equal(t, entity.StateSubmitted, readRecord(t, env, record.Id).State)
|
||||
|
||||
// Ожидание чужой операции попытку не тратит и опрос не учащает: шаг отработал
|
||||
// без отказа, и задержка у него своя, числом.
|
||||
func TestCheckJobWaitsWithoutSpendingAttempts(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
|
||||
job := transcribingJob(t, env, rec)
|
||||
entered := readRecord(t, env, record.Id).StateEnteredAt
|
||||
|
||||
svc := withRecognizer(env, rec)
|
||||
var delays []int64
|
||||
for range 3 {
|
||||
clearDelay(t, env, record.Id)
|
||||
require.NoError(t, env.service.RunStep(t.Context()))
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
|
||||
after := readRecord(t, env, record.Id)
|
||||
require.Equal(t, entity.StateSubmitted, after.State, "рубеж не сдвинут откладыванием")
|
||||
assert.Equal(t, 0, after.Attempts, "ожидание чужой операции отказа не тратит")
|
||||
assert.WithinDuration(t, entered, after.StateEnteredAt, 0, "и отсчёт застревания не двигает")
|
||||
|
||||
after, err := readJob(env.app, 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)
|
||||
delays = append(delays, after.DelayTime.Unix())
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ операции распознавания — приговор записи: задача уходит в `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(t.Context()))
|
||||
|
||||
after, err := readJob(env.app, 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(t.Context()))
|
||||
|
||||
after, err := readJob(env.app, 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(t.Context()))
|
||||
|
||||
after, err := readJob(env.app, 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(migrations.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(t.Context(), acquired, "mine")
|
||||
|
||||
var lost *contract.LostAcquisitionError
|
||||
require.ErrorAs(t, err, &lost)
|
||||
|
||||
after, err := readJob(env.app, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateTranscribe, after.State, "результат не записан")
|
||||
assert.Empty(t, env.sender.messages, "отправителю ничего не отправлено")
|
||||
assert.Len(t, delays, 3, "все три прогона отложили работу")
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"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/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
@@ -28,43 +27,43 @@ func (c *killedConverter) Convert(ctx context.Context, _, _ string) error {
|
||||
return errors.New("ffmpeg conversion failed: signal: killed")
|
||||
}
|
||||
|
||||
// Остановка сервиса посреди конвертации не выносит записи приговора: задача
|
||||
// остаётся пригодной к повтору, попытку не тратит и отправителю о сбое,
|
||||
// которого не было, не сообщает. Прежде любой отказ `Convert` уводил задачу в
|
||||
// терминальное `failed`, откуда её возвращает только владелец правкой в панели.
|
||||
func TestShutdownDuringConversionKeepsJobRetryable(t *testing.T) {
|
||||
// Критерий приёмки 9. Остановка сервиса посреди приведения не выносит записи
|
||||
// приговора и **не тратит отказа**: запись не виновата в том, что нас
|
||||
// перезапустили, и несколько выкладок подряд иначе останавливают здоровую
|
||||
// многочасовую запись с приговором «попытки исчерпаны».
|
||||
func TestShutdownDuringConversionKeepsRecordRetryable(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
|
||||
converter := &killedConverter{cancel: cancel}
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, converter)
|
||||
job := newTelegramJob(t, env)
|
||||
record := newTelegramRecord(t, env)
|
||||
|
||||
err := env.service.FindAndRunConversionJob(ctx)
|
||||
err := env.service.RunStep(ctx)
|
||||
|
||||
require.Error(t, err, "шаг обязан сообщить об обрыве наверх")
|
||||
require.ErrorIs(t, err, context.Canceled, "обрыв узнаётся по смыслу, а не по тексту")
|
||||
|
||||
after, err := readJob(env.app, job.Id)
|
||||
require.NoError(t, err)
|
||||
after := readRecord(t, env, record.Id)
|
||||
|
||||
assert.Equal(t, entity.StateCreated, after.State, "задача осталась на повтор, а не похоронена")
|
||||
assert.Nil(t, after.AcquisitionID, "захват снят: задачу возьмёт следующий прогон")
|
||||
assert.Equal(t, 0, after.Attempts, "остановка попытки не тратит")
|
||||
assert.Nil(t, after.ErrorText, "приговора не выносили")
|
||||
assert.Empty(t, env.sender.messages, "отправителю о несуществующем сбое не сообщают")
|
||||
assert.Equal(t, entity.StateUploaded, after.State, "запись осталась на повтор")
|
||||
assert.False(t, after.IsHalted(), "приговора не выносили")
|
||||
assert.Nil(t, after.AcquisitionID, "захват снят: запись возьмёт следующий прогон")
|
||||
assert.Equal(t, 0, after.Attempts, "остановка отказа не тратит")
|
||||
assert.Nil(t, after.ErrorText)
|
||||
assert.Empty(t, env.sender.sent(), "отправителю о несуществующем сбое не сообщают")
|
||||
}
|
||||
|
||||
// Задача, которую шаг не успел взять, потому что нас уже остановили, остаётся
|
||||
// нетронутой: захват не случился, попытка не потрачена.
|
||||
func TestShutdownBeforeStepLeavesJobUntouched(t *testing.T) {
|
||||
// Запись, которую шаг не успел взять, потому что нас уже остановили, остаётся
|
||||
// нетронутой: захват не случился, отказ не потрачен.
|
||||
func TestShutdownBeforeStepLeavesRecordUntouched(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
job := newTelegramJob(t, env)
|
||||
record := newTelegramRecord(t, env)
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
err := env.service.FindAndRunConversionJob(ctx)
|
||||
err := env.service.RunStep(ctx)
|
||||
|
||||
// Исход «шаг не сделал ничего» — это `NoopJobError`: воркер не пишет о нём
|
||||
// владельцу и не считает его в метрику.
|
||||
@@ -72,12 +71,8 @@ func TestShutdownBeforeStepLeavesJobUntouched(t *testing.T) {
|
||||
var noop *contract.NoopJobError
|
||||
require.ErrorAs(t, err, &noop)
|
||||
|
||||
after, err := readJob(env.app, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateCreated, after.State)
|
||||
assert.Equal(t, 0, after.Attempts, "захвата не было — попытке взяться неоткуда")
|
||||
|
||||
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, record.GetString("acquisition_id"))
|
||||
after := readRecord(t, env, record.Id)
|
||||
assert.Equal(t, entity.StateUploaded, after.State)
|
||||
assert.Equal(t, 0, after.Attempts, "захвата не было — отказу взяться неоткуда")
|
||||
assert.Nil(t, after.AcquisitionID)
|
||||
}
|
||||
|
||||
+658
-337
File diff suppressed because it is too large
Load Diff
@@ -14,10 +14,10 @@ import (
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Ответ отправителю уходит после того, как достигнутое состояние сохранено.
|
||||
// Значит, недоставка не может быть отказом шага: объявленный отказ засчитался
|
||||
// бы воркеру сбоем, лёг бы владельцу записью отказа и переписал бы служебные
|
||||
// поля завершённой задачи. Причин недоставки две, исход у них общий.
|
||||
// Ответ отправителю уходит после того, как достигнутый рубеж сохранён. Значит,
|
||||
// недоставка не может быть отказом шага: объявленный отказ засчитался бы воркеру
|
||||
// сбоем, лёг бы владельцу записью отказа и переписал бы служебные поля
|
||||
// доведённой записи. Причин недоставки две, исход у них общий.
|
||||
|
||||
// downSender изображает неподнятый канал доставки: так ведёт себя заглушка,
|
||||
// которую ядро получает вместо отправителя Telegram.
|
||||
@@ -31,91 +31,105 @@ func (s *downSender) Send(string, int64, *int) error {
|
||||
}
|
||||
|
||||
// journalEnv пересобирает сервис с названным отправителем и своим журналом:
|
||||
// утверждения судят и состояние задачи, и то, что увидел владелец.
|
||||
// утверждения судят и состояние записи, и то, что увидел владелец.
|
||||
func journalEnv(
|
||||
t *testing.T,
|
||||
env *pipelineEnv,
|
||||
rec contract.AudioRecognizer,
|
||||
sender contract.TelegramMessageSender,
|
||||
) (*TranscribeService, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
|
||||
journal := &bytes.Buffer{}
|
||||
svc := NewTranscribeService(
|
||||
env.jobRepo,
|
||||
env.fileRepo,
|
||||
env.repos,
|
||||
&okMetaViewer{},
|
||||
&failingConverter{},
|
||||
rec,
|
||||
&okConverter{},
|
||||
env.service.recognizer,
|
||||
sender,
|
||||
testLimits,
|
||||
slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug})),
|
||||
)
|
||||
|
||||
return svc, journal
|
||||
}
|
||||
|
||||
// Канал не поднят: задача доводится до конца, шаг отказа не объявляет, а
|
||||
// transcribedRecord доводит запись до рубежа, с которого уходит ответ.
|
||||
func transcribedRecord(t *testing.T, env *pipelineEnv, svc *TranscribeService) *entity.AudioRecord {
|
||||
t.Helper()
|
||||
|
||||
record := newTelegramRecord(t, env)
|
||||
for range 5 {
|
||||
clearDelay(t, env, record.Id)
|
||||
require.NoError(t, svc.RunStep(t.Context()))
|
||||
if readRecord(t, env, record.Id).State == entity.StateTranscribed {
|
||||
return readRecord(t, env, record.Id)
|
||||
}
|
||||
}
|
||||
t.Fatal("запись не дошла до рубежа расшифровки")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Канал не поднят: запись доводится до конца, шаг отказа не объявляет, а
|
||||
// владелец узнаёт о недоставке из журнала.
|
||||
func TestUndeliveredOnDownChannelKeepsJobDone(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
|
||||
job := transcribingJob(t, env, rec)
|
||||
func TestUndeliveredOnDownChannelKeepsRecordDone(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||
|
||||
rec.result = entity.NewCompletedResult()
|
||||
rec.text = "расшифровка записи"
|
||||
sender := &downSender{}
|
||||
svc, journal := journalEnv(t, env, rec, sender)
|
||||
svc, journal := journalEnv(t, env, sender)
|
||||
|
||||
record := transcribedRecord(t, env, svc)
|
||||
|
||||
// Шаг завершается без отказа — именно это воркер считает в свой счётчик.
|
||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
|
||||
clearDelay(t, env, record.Id)
|
||||
require.NoError(t, svc.RunStep(t.Context()))
|
||||
|
||||
assert.Equal(t, 1, sender.calls, "ответ до отправителя доехал")
|
||||
|
||||
after, err := readJob(env.app, job.Id)
|
||||
after := readRecord(t, env, record.Id)
|
||||
assert.Equal(t, entity.StateDone, after.State, "запись осталась на достигнутом рубеже")
|
||||
assert.False(t, after.IsHalted(), "отказ записи не приписан")
|
||||
require.NotNil(t, after.TranscriptTextID, "расшифровка сохранена")
|
||||
|
||||
text, err := env.repos.Texts.GetByID(*after.TranscriptTextID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateDone, after.State, "задача осталась в достигнутом состоянии")
|
||||
require.NotNil(t, after.TranscriptionText)
|
||||
assert.Equal(t, "расшифровка записи", *after.TranscriptionText, "расшифровка сохранена")
|
||||
assert.Nil(t, after.ErrorText, "отказ задаче не приписан")
|
||||
require.NotEmpty(t, text.Contents)
|
||||
|
||||
written := journal.String()
|
||||
assert.Contains(t, written, "Reply was not delivered", "недоставка названа")
|
||||
assert.Contains(t, written, job.Id, "запись несёт идентификатор задачи")
|
||||
assert.Contains(t, written, record.Id, "запись несёт идентификатор")
|
||||
assert.Contains(t, written, "level=WARN", "объявленный режим — «может стать проблемой»")
|
||||
assert.NotContains(t, written, "расшифровка записи", "текста расшифровки в журнале нет")
|
||||
assert.NotContains(t, written, text.Contents, "текста расшифровки в журнале нет")
|
||||
}
|
||||
|
||||
// Адресат у задачи не назван: исход тот же. Прежде эта ветка объявляла отказ
|
||||
// Адресат у записи не назван: исход тот же. Прежде эта ветка объявляла отказ
|
||||
// шага на уже завершённой работе.
|
||||
func TestUndeliveredWithoutChatKeepsJobDone(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
rec := &scriptedRecognizer{result: entity.NewInProgressResult()}
|
||||
job := transcribingJob(t, env, rec)
|
||||
func TestUndeliveredWithoutChatKeepsRecordDone(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||
|
||||
// Задача из Telegram, у которой чат не назван: такую отдаёт правка в панели.
|
||||
// Колонка чистится мимо захвата — иначе setup унёс бы задачу у шага.
|
||||
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
|
||||
require.NoError(t, err)
|
||||
record.Set("tg_chat_id", nil)
|
||||
require.NoError(t, env.app.Save(record))
|
||||
|
||||
rec.result = entity.NewCompletedResult()
|
||||
rec.text = "расшифровка записи"
|
||||
sender := &downSender{}
|
||||
svc, journal := journalEnv(t, env, rec, sender)
|
||||
svc, journal := journalEnv(t, env, sender)
|
||||
|
||||
require.NoError(t, svc.FindAndRunTranscribeCheckJob(t.Context()))
|
||||
record := transcribedRecord(t, env, svc)
|
||||
|
||||
// Запись из Telegram, у которой чат не назван: такую отдаёт правка в панели.
|
||||
// Колонка чистится мимо захвата — иначе подготовка унесла бы запись у шага.
|
||||
stored, err := env.app.FindRecordById(migrations.RecordsCollection, record.Id)
|
||||
require.NoError(t, err)
|
||||
stored.Set("tg_chat_id", nil)
|
||||
require.NoError(t, env.app.Save(stored))
|
||||
|
||||
clearDelay(t, env, record.Id)
|
||||
require.NoError(t, svc.RunStep(t.Context()))
|
||||
|
||||
assert.Equal(t, 0, sender.calls, "до отправителя дело не дошло: адресата нет")
|
||||
|
||||
after, err := readJob(env.app, job.Id)
|
||||
require.NoError(t, err)
|
||||
after := readRecord(t, env, record.Id)
|
||||
assert.Equal(t, entity.StateDone, after.State)
|
||||
assert.Nil(t, after.ErrorText, "отказ задаче не приписан")
|
||||
assert.False(t, after.IsHalted(), "отказ записи не приписан")
|
||||
|
||||
written := journal.String()
|
||||
assert.Contains(t, written, "Reply was not delivered")
|
||||
assert.Contains(t, written, job.Id)
|
||||
assert.Contains(t, written, record.Id)
|
||||
assert.Contains(t, written, "chat is not specified", "причина названа")
|
||||
assert.Contains(t, written, "level=ERROR",
|
||||
"порча записи громче штатного «бот не настроен»: иначе сигнал утонет")
|
||||
@@ -123,17 +137,17 @@ func TestUndeliveredWithoutChatKeepsJobDone(t *testing.T) {
|
||||
|
||||
// Запись, принятая по HTTP, до отправителя не доходит вовсе: недоставки нет, и
|
||||
// записи о ней в журнале быть не должно — иначе журнал владельца заполнят
|
||||
// строки о задачах основного входа.
|
||||
func TestApiJobDoesNotReachSenderAndLogsNothing(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
// строки о записях основного входа.
|
||||
func TestApiRecordDoesNotReachSenderAndLogsNothing(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
|
||||
|
||||
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "voice.ogg", newOwner(t, env.app))
|
||||
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "voice.ogg", newOwner(t, env.app))
|
||||
require.NoError(t, err)
|
||||
|
||||
sender := &downSender{}
|
||||
svc, journal := journalEnv(t, env, &scriptedRecognizer{}, sender)
|
||||
svc, journal := journalEnv(t, env, sender)
|
||||
|
||||
require.NoError(t, svc.send(job, "расшифровка записи"))
|
||||
require.NoError(t, svc.send(record, "расшифровка записи"))
|
||||
|
||||
assert.Equal(t, 0, sender.calls, "отправителя не звали")
|
||||
assert.NotContains(t, journal.String(), "Reply was not delivered", "недоставки не было")
|
||||
|
||||
Reference in New Issue
Block a user