Отмена доходит до внешнего собеседника, а токен не покидает единой точки
- контекст проложен от воркера и обоих входов до внешних вызовов: 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:
@@ -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):
|
||||
// Продолжаем работу
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user