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

- контекст проложен от воркера и обоих входов до внешних вызовов: 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
+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)
}
}