- audiorecords вместо transcribe_jobs: приложения (texts, structures, recognitions, record_events, topics) живут своими коллекциями, ссылки на исходник и на приведённую копию перестали переставляться - рубеж называет достигнутое, отказ стал признаком остановки с причиной, а сторожей стало двое: число отказов и время в рубеже - воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
200 lines
7.8 KiB
Go
200 lines
7.8 KiB
Go
package worker
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
|
)
|
|
|
|
// Проверки этого файла судят одну развилку воркера: пустой прогон против
|
|
// отказа. Инвариант проекта — «NoopJobError не ошибка» — стоит ровно на ней, а
|
|
// цена срабатывания отложенная: воркеры опрашивают базу раз в секунду, и пустой
|
|
// прогон, принятый за отказ, даёт запись в секунду с каждого и столько же
|
|
// засчитанных сбоев, которых не было.
|
|
//
|
|
// Счёт работы здесь не судится: он переехал в шаг конвейера вместе с меткой
|
|
// рубежа. Воркер к рубежу не привязан и назвать его не может, а метка,
|
|
// выведенная из имени потока, перестала что-либо значить с появлением пула.
|
|
|
|
// journalBuffer собирает журнал прогона. Пишут в него из горутины воркера, а
|
|
// читает проверка — отсюда мьютекс.
|
|
type journalBuffer struct {
|
|
mu sync.Mutex
|
|
text strings.Builder
|
|
}
|
|
|
|
func (b *journalBuffer) Write(p []byte) (int, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.text.Write(p)
|
|
}
|
|
|
|
func (b *journalBuffer) String() string {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.text.String()
|
|
}
|
|
|
|
// runOnce прогоняет воркер ровно один раз и возвращает журнал этого прогона.
|
|
//
|
|
// Воркер останавливает **второй** прогон, а не первый: отменённый контекст
|
|
// теперь и значит «нас остановили», и отмена изнутри первого шага сделала бы
|
|
// его исход неотличимым от остановки — проверка судила бы не то, что заявляет.
|
|
// Пауза между прогонами на время проверки укорочена до миллисекунды: ждать
|
|
// секунду ради второго вызова незачем.
|
|
func runOnce(t *testing.T, name string, work func(ctx context.Context) error) string {
|
|
t.Helper()
|
|
|
|
journal := &journalBuffer{}
|
|
logger := slog.New(slog.NewTextHandler(journal, nil))
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
var once sync.Once
|
|
done := make(chan struct{})
|
|
calls := 0
|
|
|
|
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() {
|
|
w.Start(ctx)
|
|
close(finished)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("работа воркера не была вызвана")
|
|
}
|
|
|
|
select {
|
|
case <-finished:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("воркер не остановился по отмене контекста")
|
|
}
|
|
|
|
return journal.String()
|
|
}
|
|
|
|
// runRecords оставляет от журнала только записи об исходе прогона. Жизненный
|
|
// цикл самого воркера — старт и остановка — по конвенции идёт на INFO и к
|
|
// прогону не относится; требование говорит о том, что воркер пишет про свой
|
|
// прогон, а не о том, что он молчит вообще.
|
|
func runRecords(journal string) string {
|
|
var kept []string
|
|
for _, line := range strings.Split(strings.TrimSpace(journal), "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if strings.Contains(line, "msg=\"Worker started\"") ||
|
|
strings.Contains(line, "msg=\"Worker received shutdown signal") {
|
|
continue
|
|
}
|
|
kept = append(kept, line)
|
|
}
|
|
return strings.Join(kept, "\n")
|
|
}
|
|
|
|
// Обёртка `%w` объявлена конвенцией проекта умолчанием, и до этой задачи первая
|
|
// же обёртка на пути сломала бы распознавание молча. Оракул держит именно
|
|
// обёрнутое значение: на голом признак узнавался и приведением типа, то есть
|
|
// проверка прошла бы и на починенном, и на сломанном коде.
|
|
func TestWrappedNoopIsNotAFailure(t *testing.T) {
|
|
const name = "wrapped_noop_worker"
|
|
|
|
journal := runOnce(t, name, func(context.Context) error {
|
|
return fmt.Errorf("find and acquire record: %w", &contract.NoopJobError{State: "uploaded"})
|
|
})
|
|
|
|
// Записи о старте и остановке воркера законны и к прогону не относятся —
|
|
// проверяется отсутствие записи об исходе прогона.
|
|
if got := runRecords(journal); got != "" {
|
|
t.Errorf("пустой прогон попал в журнал: %q", got)
|
|
}
|
|
}
|
|
|
|
// Без этой проверки оракул был бы зелен и на коде, который не пишет об отказе
|
|
// вообще ничего. Счёт отказа судит проверка шага: метку рубежа знает он.
|
|
func TestFailureIsLogged(t *testing.T) {
|
|
const name = "failing_worker"
|
|
|
|
journal := runOnce(t, name, func(context.Context) error {
|
|
return errors.New("database is gone")
|
|
})
|
|
|
|
if !strings.Contains(journal, "database is gone") {
|
|
t.Errorf("отказ не виден владельцу: журнал %q", journal)
|
|
}
|
|
}
|
|
|
|
// Успешный прогон отказом не записывается.
|
|
func TestSuccessIsNotLoggedAsFailure(t *testing.T) {
|
|
const name = "successful_worker"
|
|
|
|
journal := runOnce(t, name, func(context.Context) error {
|
|
return nil
|
|
})
|
|
|
|
if strings.Contains(journal, "Worker error") {
|
|
t.Errorf("успешный прогон записан отказом: журнал %q", journal)
|
|
}
|
|
}
|
|
|
|
// Остановка сервиса — не отказ шага: контекст отменили мы сами. Без этой
|
|
// развилки каждая выкладка красит журнал владельца отказами, — тот же довод, по
|
|
// которому не пишется `NoopJobError`. Судит контекст, а не текст ошибки: убитый по контексту
|
|
// процесс отдаёт «signal: killed», и `errors.Is` его с отменой не свяжет.
|
|
func TestShutdownIsNotAFailure(t *testing.T) {
|
|
const name = "stopped_worker"
|
|
|
|
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)
|
|
}
|
|
}
|