внутренняя модель перестроена вокруг аудиозаписи
- audiorecords вместо transcribe_jobs: приложения (texts, structures, recognitions, record_events, topics) живут своими коллекциями, ссылки на исходник и на приведённую копию перестали переставляться - рубеж называет достигнутое, отказ стал признаком остановки с причиной, а сторожей стало двое: число отказов и время в рубеже - воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Пул — предмет этой работы: воркеров стало сколько угодно одинаковых вместо
|
||||
// трёх именованных. Проверки ниже судят саму обвязку — подъём, остановку и
|
||||
// нулевой размер, — а не шаг, который она крутит: шаг судят проверки конвейера.
|
||||
|
||||
// countingStep считает свои вызовы и отпускает проверку, когда их набралось
|
||||
// достаточно.
|
||||
func countingStep(t *testing.T, enough int64) (func(context.Context) error, <-chan struct{}, *atomic.Int64) {
|
||||
t.Helper()
|
||||
|
||||
var calls atomic.Int64
|
||||
done := make(chan struct{})
|
||||
var closed atomic.Bool
|
||||
|
||||
return func(context.Context) error {
|
||||
if calls.Add(1) >= enough && closed.CompareAndSwap(false, true) {
|
||||
close(done)
|
||||
}
|
||||
return nil
|
||||
}, done, &calls
|
||||
}
|
||||
|
||||
// Пул поднимает столько воркеров, сколько ему назвали, и все они крутят шаг.
|
||||
func TestPoolRunsEveryWorker(t *testing.T) {
|
||||
const size = 4
|
||||
|
||||
step, done, calls := countingStep(t, size)
|
||||
pool := NewPool(size, step, slog.New(slog.DiscardHandler))
|
||||
for _, w := range pool.workers {
|
||||
w.interval = time.Millisecond
|
||||
}
|
||||
|
||||
if pool.Size() != size {
|
||||
t.Fatalf("в пуле %d воркеров вместо %d", pool.Size(), size)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
finished := make(chan struct{})
|
||||
go func() {
|
||||
pool.Start(ctx)
|
||||
close(finished)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("шаг позвали %d раз вместо %d: не все воркеры поднялись", calls.Load(), size)
|
||||
}
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-finished:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("пул не дождался остановки воркеров: горутина осталась висеть")
|
||||
}
|
||||
}
|
||||
|
||||
// Нулевой пул — законный режим, а не поломка: сервис поднимается, записи
|
||||
// принимаются и не двигаются. Проверка судит именно это: шаг не зовётся ни разу,
|
||||
// а подъём не блокируется.
|
||||
func TestZeroPoolRunsNothingAndReturns(t *testing.T) {
|
||||
var calls atomic.Int64
|
||||
pool := NewPool(0, func(context.Context) error {
|
||||
calls.Add(1)
|
||||
return nil
|
||||
}, slog.New(slog.DiscardHandler))
|
||||
|
||||
if pool.Size() != 0 {
|
||||
t.Fatalf("пустой пул завёл %d воркеров", pool.Size())
|
||||
}
|
||||
|
||||
finished := make(chan struct{})
|
||||
go func() {
|
||||
pool.Start(context.Background())
|
||||
close(finished)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-finished:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("пустой пул не вернул управление: подъём сервиса заблокирован")
|
||||
}
|
||||
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Errorf("шаг позвали %d раз при нулевом пуле", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Отменённый контекст останавливает **всех** воркеров пула: забытая горутина не
|
||||
// падает и не пишет, а держит процесс и продолжает опрашивать базу после
|
||||
// остановки сервиса.
|
||||
func TestPoolStopsEveryWorkerOnCancel(t *testing.T) {
|
||||
const size = 3
|
||||
|
||||
step, done, _ := countingStep(t, size)
|
||||
pool := NewPool(size, step, slog.New(slog.DiscardHandler))
|
||||
for _, w := range pool.workers {
|
||||
w.interval = time.Millisecond
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
finished := make(chan struct{})
|
||||
go func() {
|
||||
pool.Start(ctx)
|
||||
close(finished)
|
||||
}()
|
||||
|
||||
<-done
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-finished:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("пул не остановился по отмене контекста")
|
||||
}
|
||||
}
|
||||
@@ -3,26 +3,25 @@ package worker
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||
)
|
||||
|
||||
// Worker представляет базовый интерфейс для всех воркеров
|
||||
type Worker interface {
|
||||
Start(ctx context.Context)
|
||||
Name() string
|
||||
}
|
||||
|
||||
// pollInterval — пауза между прогонами шага. Полем, а не константой по месту:
|
||||
// проверке нужен второй прогон, чтобы остановить воркер **после** того, как он
|
||||
// рассудил об исходе первого. Отменять контекст изнутри шага она не может —
|
||||
// отменённый контекст теперь и значит «нас остановили».
|
||||
const pollInterval = time.Second
|
||||
|
||||
// CallbackWorker крутит один и тот же шаг, опрашивая очередь.
|
||||
//
|
||||
// Специализации у него нет: шаг сам берёт любую пригодную к работе запись и
|
||||
// выбирает работу по её рубежу. Раньше воркеров было три именованных, по одному
|
||||
// на состояние, и каждый новый рубеж требовал четвёртого.
|
||||
type CallbackWorker struct {
|
||||
name string
|
||||
// Шаг принимает контекст воркера: остановка обязана доходить до чужой
|
||||
@@ -64,15 +63,12 @@ func (w *CallbackWorker) Start(ctx context.Context) {
|
||||
// первой же обёртки `%w`, которая в проекте — умолчание.
|
||||
var noop *contract.NoopJobError
|
||||
isNoop := errors.As(err, &noop)
|
||||
// Остановка — не отказ шага: контекст отменили мы сами. Считать её
|
||||
// в метрику и писать владельцу «Worker error» значит красить каждую
|
||||
// выкладку как поломку — по тому же доводу, по которому не считается
|
||||
// Остановка — не отказ шага: контекст отменили мы сами. Писать
|
||||
// владельцу «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 && !stopped {
|
||||
w.logger.Error("Worker error", "worker", w.Name(), "error", err)
|
||||
}
|
||||
@@ -80,6 +76,9 @@ func (w *CallbackWorker) Start(ctx context.Context) {
|
||||
w.logger.Info("Worker step interrupted by shutdown", "worker", w.Name())
|
||||
}
|
||||
|
||||
// Счётчик работы растит сам шаг: только он знает рубеж, с которого
|
||||
// взята запись, а воркер к рубежу больше не привязан.
|
||||
|
||||
// Ждем перед следующей итерацией
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -91,3 +90,51 @@ func (w *CallbackWorker) Start(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pool — пул одинаковых воркеров конвейера.
|
||||
//
|
||||
// Число задаётся настройкой, и ноль — законное значение: сервис поднимается,
|
||||
// записи принимаются и не двигаются. Это режим, а не поломка: он нужен местному
|
||||
// запуску и выкладке, где конвейер надо остановить, не роняя приём.
|
||||
type Pool struct {
|
||||
workers []*CallbackWorker
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewPool собирает пул из size одинаковых воркеров, крутящих один и тот же шаг.
|
||||
func NewPool(size int, step func(ctx context.Context) error, logger *slog.Logger) *Pool {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
workers := make([]*CallbackWorker, 0, size)
|
||||
for i := range size {
|
||||
workers = append(workers, NewCallbackWorker(fmt.Sprintf("pipeline_worker_%d", i+1), step, logger))
|
||||
}
|
||||
|
||||
return &Pool{workers: workers, logger: logger}
|
||||
}
|
||||
|
||||
// Size — сколько воркеров в пуле.
|
||||
func (p *Pool) Size() int { return len(p.workers) }
|
||||
|
||||
// Start поднимает всех воркеров пула и ждёт их остановки.
|
||||
func (p *Pool) Start(ctx context.Context) {
|
||||
if len(p.workers) == 0 {
|
||||
// Молчать нельзя: пустой пул неотличим от поломки, а объявленный режим
|
||||
// обязан быть назван.
|
||||
p.logger.Info("Pipeline workers are disabled by configuration")
|
||||
return
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, w := range p.workers {
|
||||
wg.Add(1)
|
||||
go func(worker *CallbackWorker) {
|
||||
defer wg.Done()
|
||||
worker.Start(ctx)
|
||||
p.logger.Info("Worker stopped gracefully", "worker", worker.Name())
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
@@ -11,14 +11,17 @@ import (
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
// Проверки этого файла судят одну развилку воркера: пустой прогон против
|
||||
// отказа. Инвариант проекта — «NoopJobError не ошибка» — стоит ровно на ней, а
|
||||
// цена срабатывания отложенная: три воркера опрашивают базу раз в секунду, и
|
||||
// пустой прогон, принятый за отказ, даёт три записи в секунду и столько же
|
||||
// цена срабатывания отложенная: воркеры опрашивают базу раз в секунду, и пустой
|
||||
// прогон, принятый за отказ, даёт запись в секунду с каждого и столько же
|
||||
// засчитанных сбоев, которых не было.
|
||||
//
|
||||
// Счёт работы здесь не судится: он переехал в шаг конвейера вместе с меткой
|
||||
// рубежа. Воркер к рубежу не привязан и назвать его не может, а метка,
|
||||
// выведенная из имени потока, перестала что-либо значить с появлением пула.
|
||||
|
||||
// journalBuffer собирает журнал прогона. Пишут в него из горутины воркера, а
|
||||
// читает проверка — отсюда мьютекс.
|
||||
@@ -113,39 +116,6 @@ func runRecords(journal string) string {
|
||||
return strings.Join(kept, "\n")
|
||||
}
|
||||
|
||||
// jobCount читает счётчик работы воркера из общего реестра процесса. Судит
|
||||
// реестр, а не переменную пакета: метка, потерянная в точке употребления,
|
||||
// переменную не ломает, а на странице метрик видна.
|
||||
func jobCount(t *testing.T, worker, errLabel string) float64 {
|
||||
t.Helper()
|
||||
|
||||
families, err := prometheus.DefaultGatherer.Gather()
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось собрать метрики: %v", err)
|
||||
}
|
||||
|
||||
for _, mf := range families {
|
||||
if mf.GetName() != "transcriber_worker_job_count" {
|
||||
continue
|
||||
}
|
||||
for _, m := range mf.GetMetric() {
|
||||
var gotWorker, gotErr string
|
||||
for _, label := range m.GetLabel() {
|
||||
switch label.GetName() {
|
||||
case "name":
|
||||
gotWorker = label.GetValue()
|
||||
case "error":
|
||||
gotErr = label.GetValue()
|
||||
}
|
||||
}
|
||||
if gotWorker == worker && gotErr == errLabel {
|
||||
return m.GetCounter().GetValue()
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Обёртка `%w` объявлена конвенцией проекта умолчанием, и до этой задачи первая
|
||||
// же обёртка на пути сломала бы распознавание молча. Оракул держит именно
|
||||
// обёрнутое значение: на голом признак узнавался и приведением типа, то есть
|
||||
@@ -153,11 +123,8 @@ func jobCount(t *testing.T, worker, errLabel string) float64 {
|
||||
func TestWrappedNoopIsNotAFailure(t *testing.T) {
|
||||
const name = "wrapped_noop_worker"
|
||||
|
||||
before := jobCount(t, name, "false")
|
||||
beforeErr := jobCount(t, name, "true")
|
||||
|
||||
journal := runOnce(t, name, func(context.Context) error {
|
||||
return fmt.Errorf("find and acquire job: %w", &contract.NoopJobError{State: "created"})
|
||||
return fmt.Errorf("find and acquire record: %w", &contract.NoopJobError{State: "uploaded"})
|
||||
})
|
||||
|
||||
// Записи о старте и остановке воркера законны и к прогону не относятся —
|
||||
@@ -165,21 +132,13 @@ func TestWrappedNoopIsNotAFailure(t *testing.T) {
|
||||
if got := runRecords(journal); got != "" {
|
||||
t.Errorf("пустой прогон попал в журнал: %q", got)
|
||||
}
|
||||
if got := jobCount(t, name, "false"); got != before {
|
||||
t.Errorf("счётчик успешных прогонов вырос на пустом прогоне: было %v, стало %v", before, got)
|
||||
}
|
||||
if got := jobCount(t, name, "true"); got != beforeErr {
|
||||
t.Errorf("пустой прогон засчитан отказом: было %v, стало %v", beforeErr, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Без этой проверки оракул был бы зелен и на коде, который не считает отказом
|
||||
// вообще ничего.
|
||||
func TestFailureIsLoggedAndCounted(t *testing.T) {
|
||||
// Без этой проверки оракул был бы зелен и на коде, который не пишет об отказе
|
||||
// вообще ничего. Счёт отказа судит проверка шага: метку рубежа знает он.
|
||||
func TestFailureIsLogged(t *testing.T) {
|
||||
const name = "failing_worker"
|
||||
|
||||
before := jobCount(t, name, "true")
|
||||
|
||||
journal := runOnce(t, name, func(context.Context) error {
|
||||
return errors.New("database is gone")
|
||||
})
|
||||
@@ -187,42 +146,28 @@ func TestFailureIsLoggedAndCounted(t *testing.T) {
|
||||
if !strings.Contains(journal, "database is gone") {
|
||||
t.Errorf("отказ не виден владельцу: журнал %q", journal)
|
||||
}
|
||||
if got := jobCount(t, name, "true"); got != before+1 {
|
||||
t.Errorf("отказ не засчитан: было %v, стало %v", before, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Счёт успешных прогонов — знаменатель доли отказов. Реализация, снявшая его,
|
||||
// проходит обе проверки выше, а владелец теряет способность отличить «три
|
||||
// прогона в секунду, все отказали» от «три отказа среди тысячи прогонов».
|
||||
func TestSuccessIsCounted(t *testing.T) {
|
||||
// Успешный прогон отказом не записывается.
|
||||
func TestSuccessIsNotLoggedAsFailure(t *testing.T) {
|
||||
const name = "successful_worker"
|
||||
|
||||
before := jobCount(t, name, "false")
|
||||
|
||||
journal := runOnce(t, name, func(context.Context) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
if got := jobCount(t, name, "false"); got != before+1 {
|
||||
t.Errorf("успешный прогон не засчитан: было %v, стало %v", before, got)
|
||||
}
|
||||
if strings.Contains(journal, "Worker error") {
|
||||
t.Errorf("успешный прогон записан отказом: журнал %q", journal)
|
||||
}
|
||||
}
|
||||
|
||||
// Остановка сервиса — не отказ шага: контекст отменили мы сами. Без этой
|
||||
// развилки каждая выкладка красит журнал владельца отказами и накручивает
|
||||
// счётчик сбоев, которых не было, — тот же довод, по которому не считается
|
||||
// `NoopJobError`. Судит контекст, а не текст ошибки: убитый по контексту
|
||||
// развилки каждая выкладка красит журнал владельца отказами, — тот же довод, по
|
||||
// которому не пишется `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))
|
||||
|
||||
@@ -251,10 +196,4 @@ func TestShutdownIsNotAFailure(t *testing.T) {
|
||||
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