Отмена доходит до внешнего собеседника, а токен не покидает единой точки

- контекст проложен от воркера и обоих входов до внешних вызовов: ffmpeg и
  ffprobe заводятся через exec.CommandContext, SpeechKit и Object Storage
  принимают ctx вместо context.Background, скачивание записи идёт запросом с
  контекстом. Прежде остановка сервиса не доходила до чужой работы вовсе
- прерванный шаг приговора не выносит: убитый по контексту ffmpeg отдаёт
  «signal: killed», от настоящего отказа неотличимо ни типом, ни errors.Is, и
  различает их только ctx.Err(). Задача остаётся на повтор, попытку не тратит и
  отправителю о несуществующем сбое не сообщает; воркер не считает остановку
  отказом, а задача не забирается вовсе, если нас уже остановили
- клиента Bot API заводит единая точка internal/adapter/telegram: токен стоит в
  пути каждого обращения, а http.Client кладёт адрес в *url.Error целиком.
  Чистка на месте употребления закрывала один вызов из пяти — теперь свой Do
  чистит отказ, подменённый логгер вычищает токен из строк самой библиотеки, а
  транспорт бота токена не получает вовсе
- принятие операции распознавания защищено от отмены своим пределом: SpeechKit
  мог её принять и начать считать деньги, а потерянный идентификатор заставил
  бы повтор оплатить ту же запись второй раз
- приём по HTTP доводит запись до задачи независимо от отправителя: на
  контексте запроса один обрыв соединения терял полностью загруженную запись
- ответ Telegram с не-2xx кодом больше не становится записью: прежде тело
  отказа доезжало до хранилища и умирало на ffprobe, уводя диагностику
This commit is contained in:
av
2026-08-13 10:27:54 +03:00
parent d8d6bcc193
commit f494dcb83e
22 changed files with 855 additions and 137 deletions
+15 -14
View File
@@ -1,6 +1,7 @@
package service
import (
"context"
"errors"
"io"
"log/slog"
@@ -29,19 +30,19 @@ import (
// failingConverter отказывает на каждой попытке.
type failingConverter struct{}
func (c *failingConverter) Convert(string, string) error {
func (c *failingConverter) Convert(context.Context, string, string) error {
return errors.New("конвертация не удалась")
}
type okMetaViewer struct{}
func (m *okMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
func (m *okMetaViewer) GetInfo(context.Context, string) (*contract.AudioInfo, error) {
return &contract.AudioInfo{Seconds: 1}, nil
}
type failingMetaViewer struct{}
func (m *failingMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
func (m *failingMetaViewer) GetInfo(context.Context, string) (*contract.AudioInfo, error) {
return nil, errors.New("запись не читается")
}
@@ -101,7 +102,7 @@ func newTelegramJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob {
t.Helper()
chatId := int64(100)
job, err := env.service.CreateJobFromTelegram(strings.NewReader("запись"), "voice.ogg", chatId, 1)
job, err := env.service.CreateJobFromTelegram(t.Context(), strings.NewReader("запись"), "voice.ogg", chatId, 1)
require.NoError(t, err)
return job
}
@@ -149,7 +150,7 @@ func TestJobDiesAfterAttemptLimit(t *testing.T) {
rotAcquisition(t, env, job.Id)
// Следующий захват видит перебор и хоронит задачу.
err := env.service.FindAndRunConversionJob()
err := env.service.FindAndRunConversionJob(t.Context())
var noop *contract.NoopJobError
require.ErrorAs(t, err, &noop, "мёртвая задача шагу не отдаётся")
@@ -178,7 +179,7 @@ func TestDeadJobReturnsAfterStateEdit(t *testing.T) {
_, err := env.jobRepo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
require.NoError(t, err)
}
require.Error(t, env.service.FindAndRunConversionJob())
require.Error(t, env.service.FindAndRunConversionJob(t.Context()))
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err)
@@ -209,7 +210,7 @@ func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
require.NoError(t, env.app.Save(record))
// Первый отказ.
require.Error(t, env.service.FindAndRunConversionJob())
require.Error(t, env.service.FindAndRunConversionJob(t.Context()))
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
@@ -220,7 +221,7 @@ func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
// Второй отказ — с той же задачи, пауза снята вручную.
clearDelay(t, env, job.Id)
require.Error(t, env.service.FindAndRunConversionJob())
require.Error(t, env.service.FindAndRunConversionJob(t.Context()))
after, err = env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
@@ -247,7 +248,7 @@ func TestWorkFileRemovedAfterIntakeFailure(t *testing.T) {
env := newPipelineEnv(t, &failingMetaViewer{}, &failingConverter{})
_, err := env.service.CreateJobFromApi(strings.NewReader("запись"), "sample.mp3")
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3")
require.Error(t, err, "отказ источника метаданных роняет приём")
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
@@ -263,7 +264,7 @@ func TestWorkFileRemovedAfterSuccessfulIntake(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
_, err := env.service.CreateJobFromApi(strings.NewReader("запись"), "sample.mp3")
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3")
require.NoError(t, err)
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
@@ -280,7 +281,7 @@ func TestJobNeverPointsToMissingFile(t *testing.T) {
// Конвертация отказывает — задача уходит в `failed`, но ссылка остаётся на
// исходную запись, а не на несозданный результат.
require.NoError(t, env.service.FindAndRunConversionJob())
require.NoError(t, env.service.FindAndRunConversionJob(t.Context()))
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
@@ -298,7 +299,7 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
content := strings.Repeat("запись ", 1000)
job, err := env.service.CreateJobFromApi(strings.NewReader(content), "sample.mp3")
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader(content), "sample.mp3")
require.NoError(t, err)
require.NotNil(t, job.FileID)
@@ -321,7 +322,7 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
func TestLocalizeGivesReadableCopy(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job, err := env.service.CreateJobFromApi(strings.NewReader("содержимое"), "sample.mp3")
job, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("содержимое"), "sample.mp3")
require.NoError(t, err)
require.NotNil(t, job.FileID)
@@ -355,7 +356,7 @@ func TestWorkFilesRemovedAfterConversionFailure(t *testing.T) {
require.Empty(t, leftovers, "приём убрал свою рабочую копию")
// Конвертация отказывает — задача уходит в `failed`, копии убраны.
require.NoError(t, env.service.FindAndRunConversionJob())
require.NoError(t, env.service.FindAndRunConversionJob(t.Context()))
leftovers, err = filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
require.NoError(t, err)