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

- контекст проложен от воркера и обоих входов до внешних вызовов: 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
+9 -1
View File
@@ -1,6 +1,7 @@
package http
import (
"context"
"log/slog"
"net/http"
"time"
@@ -71,7 +72,14 @@ func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
}
}()
job, err := h.trsService.CreateJobFromApi(file, header.Filename)
// Запись доехала целиком, поэтому задача заводится независимо от того,
// дождётся ли отправитель ответа: на контексте запроса приём терял бы
// полностью загруженную запись от одного обрыва соединения, а забрать
// результат он может и позже — по `GET /status/{id}`. Значения контекста
// (журнал запроса, сессия) при этом сохраняются, теряется только отмена.
ctx := context.WithoutCancel(e.Request.Context())
job, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename)
if err != nil {
// Второй раз отказ не логируем: приём назван конвенцией логирующей
// границей и уже написал о нём. Транспорт переводит ошибку в ответ.
+32 -2
View File
@@ -2,6 +2,7 @@ package http
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -39,7 +40,12 @@ type stubMetaViewer struct {
err error
}
func (m *stubMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
func (m *stubMetaViewer) GetInfo(ctx context.Context, _ string) (*contract.AudioInfo, error) {
// Настоящий `ffprobe` заведён с контекстом и по отмене умирает; стаб,
// который контекст игнорирует, сделал бы проверку приёма неспособной упасть.
if err := ctx.Err(); err != nil {
return nil, err
}
if m.err != nil {
return nil, m.err
}
@@ -50,7 +56,7 @@ func (m *stubMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
// этого файла её не зовёт.
type stubConverter struct{}
func (c *stubConverter) Convert(string, string) error { return nil }
func (c *stubConverter) Convert(context.Context, string, string) error { return nil }
// TestTgSender: приём по HTTP в Telegram не отвечает, но сервису отправитель нужен.
type TestTgSender struct{}
@@ -727,3 +733,27 @@ func TestGetTranscribeJobStatus_NotFound(t *testing.T) {
assert.Equal(t, "Job not found", response["error"])
}
// Отправитель, у которого соединение оборвалось после полной загрузки, задачу
// всё равно получает: запись доехала целиком, а результат он заберёт позже по
// `GET /status/{id}`. Приём на контексте запроса терял бы такую запись молча —
// решение владельца от 2026-08-13.
func TestAcceptedRecordSurvivesSenderDisconnect(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
req := createMultipartRequest(t, "sample.m4a", []byte("аудио"))
// Так выглядит ушедший отправитель: контекст запроса отменяется сервером,
// когда соединение закрылось.
ctx, cancel := context.WithCancel(req.Context())
cancel()
req = req.WithContext(ctx)
w := httptest.NewRecorder()
env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Result().StatusCode, "тело ответа: %s", w.Body.String())
jobs, err := env.app.FindAllRecords(migrations.JobsCollection)
require.NoError(t, err)
assert.Len(t, jobs, 1, "задача заведена, несмотря на ушедшего отправителя")
}
+111
View File
@@ -0,0 +1,111 @@
package tg
import (
"io"
"log/slog"
"net/http"
"strings"
"testing"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// probeClient подменяет клиента бота и запоминает, кого спрашивали. Через него
// проверяется стык: скачивание обязано идти клиентом бота, а не общим
// `http.DefaultClient` — чистку отказа от адреса с токеном несёт именно клиент
// (`internal/adapter/telegram`). Подмена на общий клиент правил гейта не
// нарушает, поэтому сторожить стык может только проверка.
type probeClient struct {
seen []string
download func(w *probeResponse)
}
type probeResponse struct {
status int
body string
}
func (c *probeClient) Do(req *http.Request) (*http.Response, error) {
c.seen = append(c.seen, req.URL.Path)
switch {
case strings.Contains(req.URL.Path, "/getMe"):
return jsonResponse(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"probe_bot"}}`), nil
case strings.Contains(req.URL.Path, "/getFile"):
return jsonResponse(`{"ok":true,"result":{"file_id":"x","file_path":"voice/file_1.ogg"}}`), nil
}
answer := &probeResponse{status: http.StatusOK, body: "аудио"}
if c.download != nil {
c.download(answer)
}
return &http.Response{
StatusCode: answer.status,
Body: io.NopCloser(strings.NewReader(answer.body)),
Header: make(http.Header),
}, nil
}
func jsonResponse(body string) *http.Response {
header := make(http.Header)
header.Set("Content-Type", "application/json")
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: header,
}
}
func newProbeController(t *testing.T, client *probeClient) *TelegramController {
t.Helper()
// Клиент подставной, поэтому адрес значения не имеет — важно лишь, что
// библиотека соберёт из него разбираемый URL.
bot, err := tgbotapi.NewBotAPIWithClient(
"7654321:AAHsecretBOTtokenVALUE",
"http://telegram.probe/bot%s/%s",
client,
)
require.NoError(t, err)
return &TelegramController{
bot: bot,
logger: slog.New(slog.DiscardHandler),
}
}
// Скачивание идёт клиентом бота: иначе отказ пойдёт мимо чистки и унесёт токен.
func TestDownloadGoesThroughBotClient(t *testing.T) {
client := &probeClient{}
controller := newProbeController(t, client)
body, name, err := controller.downloadAudioFile(t.Context(), "file-id")
require.NoError(t, err)
t.Cleanup(func() {
if err := body.Close(); err != nil {
t.Errorf("не удалось закрыть тело: %v", err)
}
})
assert.Equal(t, "voice/file_1.ogg", name)
require.Len(t, client.seen, 3, "клиент бота видел все обращения: getMe, getFile и скачивание")
assert.Contains(t, client.seen[2], "voice/file_1.ogg", "скачивание ушло мимо клиента бота")
}
// Отказ выдачи файла — это не запись: тело такого ответа не должно доехать до
// хранилища и умереть на `ffprobe`, уведя диагностику к чужой причине.
func TestDownloadRejectsNonOKStatus(t *testing.T) {
client := &probeClient{download: func(w *probeResponse) {
w.status = http.StatusUnauthorized
w.body = `{"ok":false,"error_code":401,"description":"Unauthorized"}`
}}
controller := newProbeController(t, client)
body, _, err := controller.downloadAudioFile(t.Context(), "file-id")
require.Error(t, err)
assert.Nil(t, body, "тело отказа наружу не отдают")
assert.Contains(t, err.Error(), "401", "код ответа назван — по нему видно, что отказал Telegram")
}
+55 -25
View File
@@ -1,6 +1,8 @@
package tg
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
@@ -8,6 +10,12 @@ import (
"slices"
"strings"
// Транспорт знает адаптер Telegram ровно ради единой точки чистки отказа:
// второй экземпляр той же функции здесь был бы вторым способом делать одно
// и то же, а секрет в журнале — необратим. Направление «транспорт не знает
// адаптера» правилом не держится и уже нарушено HTTP-поверхностью
// (docs/conventions/go-linters.md, «Что остаётся прозой»).
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/service"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
@@ -25,25 +33,22 @@ type TelegramController struct {
}
type TelegramConfig struct {
BotToken string
UpdateTimeout int
UserWhiteList []string
}
// NewTelegramController принимает готового клиента, а не токен: клиента заводит
// единая точка `internal/adapter/telegram`, и только её отказ не несёт секрета.
// Токен сюда не приезжает вовсе — значит, и утечь отсюда ему неоткуда.
func NewTelegramController(
config TelegramConfig,
bot *tgbotapi.BotAPI,
transcribeService *service.TranscribeService,
jobRepo contract.TranscriptJobRepository,
logger *slog.Logger,
) (*TelegramController, error) {
botToken := config.BotToken
if botToken == "" {
return nil, &EmptyBotTokenError{}
}
bot, err := tgbotapi.NewBotAPI(botToken)
if err != nil {
return nil, err
if bot == nil {
return nil, errors.New("telegram bot is not created")
}
controller := &TelegramController{
@@ -58,7 +63,11 @@ func NewTelegramController(
return controller, nil
}
func (c *TelegramController) Start() {
// Start принимает контекст жизни процесса и отдаёт его каждому обработчику:
// скачивание записи и разбор её метаданных — работа с внешним собеседником, и
// остановка сервиса обязана до неё доходить. Приём обновлений контекстом не
// правится: его прекращает Stop.
func (c *TelegramController) Start(ctx context.Context) {
c.logger.Info("Telegram bot started", "username", c.bot.Self.UserName)
u := tgbotapi.NewUpdate(0)
@@ -94,11 +103,11 @@ func (c *TelegramController) Start() {
// Handle audio messages and files
if update.Message.Audio != nil {
c.handleAudioMessage(update.Message)
c.handleAudioMessage(ctx, update.Message)
} else if update.Message.Voice != nil {
c.handleVoiceMessage(update.Message)
c.handleVoiceMessage(ctx, update.Message)
} else if update.Message.Document != nil {
c.handleDocumentMessage(update.Message)
c.handleDocumentMessage(ctx, update.Message)
}
}
}
@@ -148,7 +157,7 @@ func (c *TelegramController) handleHelpCommand(message *tgbotapi.Message) {
c.send(msg)
}
func (c *TelegramController) handleAudioMessage(message *tgbotapi.Message) {
func (c *TelegramController) handleAudioMessage(ctx context.Context, message *tgbotapi.Message) {
// Отправляем сообщение о начале обработки
progressMsg := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю аудиофайл...")
progressMsg.ReplyToMessageID = message.MessageID
@@ -159,7 +168,7 @@ func (c *TelegramController) handleAudioMessage(message *tgbotapi.Message) {
}
// Скачиваем файл
fileReader, fileName, err := c.downloadAudioFile(message.Audio.FileID)
fileReader, fileName, err := c.downloadAudioFile(ctx, message.Audio.FileID)
if err != nil {
c.logger.Error("Failed to download audio file", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании аудиофайла. Попробуйте еще раз.")
@@ -169,7 +178,7 @@ func (c *TelegramController) handleAudioMessage(message *tgbotapi.Message) {
defer fileReader.Close()
// Обрабатываем файл
job, err := c.transcribeService.CreateJobFromTelegram(fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
job, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
if err != nil {
c.logger.Error("Failed to create transcribe job", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
@@ -183,7 +192,7 @@ func (c *TelegramController) handleAudioMessage(message *tgbotapi.Message) {
c.send(successMsg)
}
func (c *TelegramController) handleVoiceMessage(message *tgbotapi.Message) {
func (c *TelegramController) handleVoiceMessage(ctx context.Context, message *tgbotapi.Message) {
// Отправляем сообщение о начале обработки
progressMsg := tgbotapi.NewMessage(message.Chat.ID, "Обрабатываю голосовое сообщение...")
progressMsg.ReplyToMessageID = message.MessageID
@@ -194,7 +203,7 @@ func (c *TelegramController) handleVoiceMessage(message *tgbotapi.Message) {
}
// Скачиваем файл
fileReader, fileName, err := c.downloadAudioFile(message.Voice.FileID)
fileReader, fileName, err := c.downloadAudioFile(ctx, message.Voice.FileID)
if err != nil {
c.logger.Error("Failed to download voice file", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании голосового сообщения. Попробуйте еще раз.")
@@ -204,7 +213,7 @@ func (c *TelegramController) handleVoiceMessage(message *tgbotapi.Message) {
defer fileReader.Close()
// Обрабатываем файл
job, err := c.transcribeService.CreateJobFromTelegram(fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
job, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
if err != nil {
c.logger.Error("Failed to create transcribe job", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
@@ -218,7 +227,7 @@ func (c *TelegramController) handleVoiceMessage(message *tgbotapi.Message) {
c.send(successMsg)
}
func (c *TelegramController) handleDocumentMessage(message *tgbotapi.Message) {
func (c *TelegramController) handleDocumentMessage(ctx context.Context, message *tgbotapi.Message) {
// Проверяем, является ли документ аудиофайлом
if !c.isAudioDocument(message.Document) {
return
@@ -234,7 +243,7 @@ func (c *TelegramController) handleDocumentMessage(message *tgbotapi.Message) {
}
// Скачиваем файл
fileReader, fileName, err := c.downloadAudioFile(message.Document.FileID)
fileReader, fileName, err := c.downloadAudioFile(ctx, message.Document.FileID)
if err != nil {
c.logger.Error("Failed to download document file", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при скачивании аудиофайла. Попробуйте еще раз.")
@@ -244,7 +253,7 @@ func (c *TelegramController) handleDocumentMessage(message *tgbotapi.Message) {
defer fileReader.Close()
// Обрабатываем файл
job, err := c.transcribeService.CreateJobFromTelegram(fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
job, err := c.transcribeService.CreateJobFromTelegram(ctx, fileReader, fileName, message.Chat.ID, sentProgressMsg.MessageID)
if err != nil {
c.logger.Error("Failed to create transcribe job", "error", err)
errorMsg := tgbotapi.NewMessage(message.Chat.ID, "Ошибка при создании задачи на расшифровку. Попробуйте еще раз.")
@@ -258,20 +267,41 @@ func (c *TelegramController) handleDocumentMessage(message *tgbotapi.Message) {
c.send(successMsg)
}
func (c *TelegramController) downloadAudioFile(fileID string) (io.ReadCloser, string, error) {
func (c *TelegramController) downloadAudioFile(ctx context.Context, fileID string) (io.ReadCloser, string, error) {
// Получаем информацию о файле
file, err := c.bot.GetFile(tgbotapi.FileConfig{FileID: fileID})
if err != nil {
return nil, "", fmt.Errorf("failed to get file info: %w", err)
}
// Скачиваем файл
// Скачиваем файл. Запрос заводится с контекстом: скачивание шестичасовой
// записи иначе продолжается и после остановки сервиса, а ссылка на файл
// несёт токен бота — держать её живой дольше нужного незачем.
//
// Клиент берётся у бота, а не `http.DefaultClient`: у бота он свой, и его
// отказ уже не несёт адреса (`internal/adapter/telegram`, единая точка).
fileURL := file.Link(c.bot.Token)
resp, err := http.Get(fileURL)
request, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
if err != nil {
return nil, "", fmt.Errorf("failed to build download request: %w", telegram.WithoutURL(err))
}
resp, err := c.bot.Client.Do(request)
if err != nil {
return nil, "", fmt.Errorf("failed to download file: %w", err)
}
// Отказ выдачи файла — это не запись. Без проверки телом «записи» станет
// JSON вида `{"ok":false,…}`: он доедет до хранилища, ляжет рабочей копией
// и умрёт на `ffprobe`, а отправитель получит жалобу на свой файл вместо
// правды о протухшей ссылке.
if resp.StatusCode != http.StatusOK {
if err := resp.Body.Close(); err != nil {
c.logger.Error("Failed to close download response", "error", err)
}
return nil, "", fmt.Errorf("failed to download file: unexpected status %d", resp.StatusCode)
}
// Получаем имя файла из URL
fileName := file.FilePath
if fileName == "" {
+31 -12
View File
@@ -17,21 +17,31 @@ type Worker interface {
Name() string
}
// pollInterval — пауза между прогонами шага. Полем, а не константой по месту:
// проверке нужен второй прогон, чтобы остановить воркер **после** того, как он
// рассудил об исходе первого. Отменять контекст изнутри шага она не может —
// отменённый контекст теперь и значит «нас остановили».
const pollInterval = time.Second
type CallbackWorker struct {
name string
f func() error
logger *slog.Logger
name string
// Шаг принимает контекст воркера: остановка обязана доходить до чужой
// работы, которую шаг завёл, а не только прерывать цикл между шагами.
f func(ctx context.Context) error
logger *slog.Logger
interval time.Duration
}
func NewCallbackWorker(name string, f func() error, logger *slog.Logger) *CallbackWorker {
func NewCallbackWorker(name string, f func(ctx context.Context) error, logger *slog.Logger) *CallbackWorker {
if logger == nil {
logger = slog.Default()
}
return &CallbackWorker{
name: name,
f: f,
logger: logger,
name: name,
f: f,
logger: logger,
interval: pollInterval,
}
}
@@ -48,25 +58,34 @@ func (w *CallbackWorker) Start(ctx context.Context) {
w.logger.Info("Worker received shutdown signal", "worker", w.Name())
return
default:
err := w.f()
err := w.f(ctx)
// Признак узнаётся по смыслу, а не по точной форме значения:
// приведение типа видело только вершину цепочки и сломалось бы от
// первой же обёртки `%w`, которая в проекте — умолчание.
var noop *contract.NoopJobError
isNoop := errors.As(err, &noop)
if !isNoop {
// Остановка — не отказ шага: контекст отменили мы сами. Считать её
// в метрику и писать владельцу «Worker error» значит красить каждую
// выкладку как поломку — по тому же доводу, по которому не считается
// `NoopJobError`. Судит контекст, а не текст ошибки: убитый процесс
// отдаёт «signal: killed», и `errors.Is` его с отменой не свяжет.
stopped := err != nil && !isNoop && ctx.Err() != nil
if !isNoop && !stopped {
metrics.WorkerJobCounter.WithLabelValues(w.Name(), strconv.FormatBool(err != nil)).Inc()
}
if err != nil && !isNoop {
if err != nil && !isNoop && !stopped {
w.logger.Error("Worker error", "worker", w.Name(), "error", err)
}
if stopped {
w.logger.Info("Worker step interrupted by shutdown", "worker", w.Name())
}
// Ждем 1 секунду перед следующей итерацией
// Ждем перед следующей итерацией
select {
case <-ctx.Done():
w.logger.Info("Worker received shutdown signal during sleep", "worker", w.Name())
return
case <-time.After(1 * time.Second):
case <-time.After(w.interval):
// Продолжаем работу
}
}
+70 -14
View File
@@ -40,10 +40,13 @@ func (b *journalBuffer) String() string {
}
// runOnce прогоняет воркер ровно один раз и возвращает журнал этого прогона.
// Цикл воркера бесконечен и спит секунду между прогонами, поэтому контекст
// отменяется сразу после первого вызова работы: ждать второго прогона нечего, а
// секунда сна на проверку — цена ни за что.
func runOnce(t *testing.T, name string, work func() error) string {
//
// Воркер останавливает **второй** прогон, а не первый: отменённый контекст
// теперь и значит «нас остановили», и отмена изнутри первого шага сделала бы
// его исход неотличимым от остановки — проверка судила бы не то, что заявляет.
// Пауза между прогонами на время проверки укорочена до миллисекунды: ждать
// секунду ради второго вызова незачем.
func runOnce(t *testing.T, name string, work func(ctx context.Context) error) string {
t.Helper()
journal := &journalBuffer{}
@@ -54,15 +57,21 @@ func runOnce(t *testing.T, name string, work func() error) string {
var once sync.Once
done := make(chan struct{})
calls := 0
w := NewCallbackWorker(name, func() error {
err := work()
once.Do(func() {
cancel()
close(done)
})
return err
w := NewCallbackWorker(name, func(ctx context.Context) error {
calls++
if calls > 1 {
// Первый прогон уже рассужен: журнал написан, счётчик сдвинут.
once.Do(func() {
cancel()
close(done)
})
return &contract.NoopJobError{State: "stopping"}
}
return work(ctx)
}, logger)
w.interval = time.Millisecond
finished := make(chan struct{})
go func() {
@@ -147,7 +156,7 @@ func TestWrappedNoopIsNotAFailure(t *testing.T) {
before := jobCount(t, name, "false")
beforeErr := jobCount(t, name, "true")
journal := runOnce(t, name, func() error {
journal := runOnce(t, name, func(context.Context) error {
return fmt.Errorf("find and acquire job: %w", &contract.NoopJobError{State: "created"})
})
@@ -171,7 +180,7 @@ func TestFailureIsLoggedAndCounted(t *testing.T) {
before := jobCount(t, name, "true")
journal := runOnce(t, name, func() error {
journal := runOnce(t, name, func(context.Context) error {
return errors.New("database is gone")
})
@@ -191,7 +200,7 @@ func TestSuccessIsCounted(t *testing.T) {
before := jobCount(t, name, "false")
journal := runOnce(t, name, func() error {
journal := runOnce(t, name, func(context.Context) error {
return nil
})
@@ -202,3 +211,50 @@ func TestSuccessIsCounted(t *testing.T) {
t.Errorf("успешный прогон записан отказом: журнал %q", journal)
}
}
// Остановка сервиса — не отказ шага: контекст отменили мы сами. Без этой
// развилки каждая выкладка красит журнал владельца отказами и накручивает
// счётчик сбоев, которых не было, — тот же довод, по которому не считается
// `NoopJobError`. Судит контекст, а не текст ошибки: убитый по контексту
// процесс отдаёт «signal: killed», и `errors.Is` его с отменой не свяжет.
func TestShutdownIsNotAFailure(t *testing.T) {
const name = "stopped_worker"
beforeErr := jobCount(t, name, "true")
beforeOk := jobCount(t, name, "false")
journal := &journalBuffer{}
logger := slog.New(slog.NewTextHandler(journal, nil))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
w := NewCallbackWorker(name, func(context.Context) error {
// Так выглядит шаг, которого застала остановка.
cancel()
return errors.New("ffmpeg conversion failed: signal: killed")
}, logger)
w.interval = time.Millisecond
finished := make(chan struct{})
go func() {
w.Start(ctx)
close(finished)
}()
select {
case <-finished:
case <-time.After(5 * time.Second):
t.Fatal("воркер не остановился по отмене контекста")
}
if got := journal.String(); strings.Contains(got, "Worker error") {
t.Errorf("остановка записана отказом: журнал %q", got)
}
if got := jobCount(t, name, "true"); got != beforeErr {
t.Errorf("остановка засчитана отказом: было %v, стало %v", beforeErr, got)
}
if got := jobCount(t, name, "false"); got != beforeOk {
t.Errorf("остановка засчитана успешным прогоном: было %v, стало %v", beforeOk, got)
}
}