внутренняя модель перестроена вокруг аудиозаписи

- audiorecords вместо transcribe_jobs: приложения (texts, structures,
  recognitions, record_events, topics) живут своими коллекциями, ссылки на
  исходник и на приведённую копию перестали переставляться
- рубеж называет достигнутое, отказ стал признаком остановки с причиной, а
  сторожей стало двое: число отказов и время в рубеже
- воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг
  выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
This commit is contained in:
av
2026-08-14 20:20:33 +03:00
parent d079f03350
commit 1576d06735
84 changed files with 8973 additions and 2865 deletions
+129
View File
@@ -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("пул не остановился по отмене контекста")
}
}
+61 -14
View File
@@ -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()
}
+14 -75
View File
@@ -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)
}
}