Files
healthlog/internal/replay/player.go
T
av 63bffe2865 Приём отвечает 200 до свёртки, свёртку ведёт фоновый воркер
- Очередью служит сама таблица: доставка ждёт свёртки в статусе `pending`,
  канал несёт только бит «есть работа». Переполнять нечего, падение процесса
  очередь не теряет, а подбор `pending` при старте — обычный проход воркера, а
  не отдельный код. Классификация исхода общая с пересборкой журнала.
- Исход разбора начал отражать доставку, а не обстоятельства: отмена и
  занятость базы статус не меняют (иначе конкуренция за базу выводила бы
  доставку из очереди навсегда), паника свёртки больше не валит процесс, а
  учёт доставки идёт через транзакцию с повторами.
- Длинный бюджет ответа выдан маршруту приёма, а не всему серверу:
  `write_timeout` в Go покрывает и чтение тела, и общий подъём снял бы защиту с
  остальных маршрутов.
2026-08-02 11:01:42 +03:00

111 lines
5.8 KiB
Go

package replay
import (
"context"
"errors"
"git.vakhrushev.me/av/healthlog/internal/fold"
"git.vakhrushev.me/av/healthlog/internal/hae"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// Outcome — исход свёртки: одной доставки или их последовательности.
//
// Классы разведены потому, что читаются по-разному. FailedLayer — штатный исход
// (слой не выводится, таких тел в журнале заведомо есть), FailedMalformed —
// содержимое не разбирается, Deferred — работа не сделана по обстоятельствам,
// и доставка осталась в очереди. Только FailedOther означает, что что-то не так
// с самой свёрткой. Один общий счётчик отправлял бы человека искать дефект там,
// где его нет.
type Outcome struct {
Folded int
FailedLayer int
FailedMalformed int
// Deferred — доставка осталась `pending`: отмена или занятость базы. Не
// отказ доставки, а несделанная работа; её подберёт следующий проход.
Deferred int
FailedOther int
// Partial — доставок, в теле которых остались непокрытые разбором секции.
// Не отклонение, а половина потока; названо потому, что именно эти тела
// ретеншену трогать нельзя.
Partial int
// Incomparable — столкновений с несравнимыми наборами полей. На живом потоке
// их не было ни разу, и на этом стоит отказ от объединения полей.
Incomparable int
}
// Add накапливает исход одной доставки в общий.
func (o *Outcome) Add(other Outcome) {
o.Folded += other.Folded
o.FailedLayer += other.FailedLayer
o.FailedMalformed += other.FailedMalformed
o.Deferred += other.Deferred
o.FailedOther += other.FailedOther
o.Partial += other.Partial
o.Incomparable += other.Incomparable
}
// classify раскладывает ошибку свёртки по классам исхода.
//
// Чистая функция, и это не украшение: она и есть та половина, которую задача
// требовала не дублировать между пересборкой и фоновым воркером, — а
// проверяется она перебором классов, без базы и без архива.
//
// Неэкспортируемая намеренно: её результат содержит поля `Partial` и
// `Incomparable`, которые дописывает только Play, — вторая публичная дверь
// молча занижала бы именно тот счётчик, по которому принимается решение о
// судьбе тела в архиве.
func classify(err error) Outcome {
var out Outcome
switch {
case err == nil:
out.Folded++
case store.Transient(err):
// Статус доставки свёртка в этих случаях не трогает: она осталась
// `pending` и будет свёрнута снова. Правило одно на обоих — то, по
// которому свёртка решает не писать исход.
out.Deferred++
case errors.Is(err, hae.ErrLayerUnknown):
out.FailedLayer++
case errors.Is(err, hae.ErrMalformed):
out.FailedMalformed++
default:
out.FailedOther++
}
return out
}
// Player сворачивает доставку по идентификатору и классифицирует исход.
//
// Общий и для пересборки журнала, и для фонового воркера приёма — второй
// классификатор разошёлся бы с первым молча, а по одному из его счётчиков
// (`Partial`) принимается решение о судьбе тела в архиве.
type Player struct {
Fold *fold.Service
}
// Play сворачивает одну доставку и возвращает её исход.
//
// Классифицируется ТОЛЬКО ошибка свёртки: на контекст Play не смотрит, и это
// существенно. У двух вызывающих отменённый контекст означает противоположное —
// у пересборки в свёртку уходит тот же отменяемый контекст («нас остановили»),
// у воркера отвязанный от остановки, с собственным дедлайном («доставка не
// уложилась в бюджет»). Решение «работу прекратили снаружи» принимает цикл,
// каждый по своему контексту.
func (p Player) Play(ctx context.Context, deliveryID string) (Outcome, error) {
st, err := p.Fold.Fold(ctx, deliveryID)
out := classify(err)
if err == nil {
// Счётчики читаются только у успешной свёртки: при ошибке поля Stats
// заполнены частично (Uncovered у отказавшего разбора всегда пуст, хотя
// в базу список записан) — и Partial молча занижался бы. А по нему
// принимается решение о ретеншене тел.
if len(st.Uncovered) > 0 {
out.Partial++
}
out.Incomparable += st.Incomparable
}
return out, err
}