внутренняя модель перестроена вокруг аудиозаписи
- audiorecords вместо transcribe_jobs: приложения (texts, structures, recognitions, record_events, topics) живут своими коллекциями, ссылки на исходник и на приведённую копию перестали переставляться - рубеж называет достигнутое, отказ стал признаком остановки с причиной, а сторожей стало двое: число отказов и время в рубеже - воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
This commit is contained in:
@@ -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, "все три прогона отложили работу")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user