Приём отвечает 200 до свёртки, свёртку ведёт фоновый воркер
- Очередью служит сама таблица: доставка ждёт свёртки в статусе `pending`, канал несёт только бит «есть работа». Переполнять нечего, падение процесса очередь не теряет, а подбор `pending` при старте — обычный проход воркера, а не отдельный код. Классификация исхода общая с пересборкой журнала. - Исход разбора начал отражать доставку, а не обстоятельства: отмена и занятость базы статус не меняют (иначе конкуренция за базу выводила бы доставку из очереди навсегда), паника свёртки больше не валит процесс, а учёт доставки идёт через транзакцию с повторами. - Длинный бюджет ответа выдан маршруту приёма, а не всему серверу: `write_timeout` в Go покрывает и чтение тела, и общий подъём снял бы защиту с остальных маршрутов.
This commit is contained in:
+81
-33
@@ -14,7 +14,6 @@ import (
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/archive"
|
||||
"git.vakhrushev.me/av/healthlog/internal/fold"
|
||||
"git.vakhrushev.me/av/healthlog/internal/ident"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
@@ -48,33 +47,56 @@ type Result struct {
|
||||
RawPath string
|
||||
}
|
||||
|
||||
// foldTimeout — сколько отводится свёртке принятой доставки.
|
||||
// recordTimeout — сколько отводится записи учёта доставки.
|
||||
//
|
||||
// Свёртка идёт на контексте, отвязанном от запроса, поэтому собственный
|
||||
// дедлайн обязателен: без него зависшая запись держала бы горутину до конца
|
||||
// жизни процесса.
|
||||
const foldTimeout = 2 * time.Minute
|
||||
// Учёт ведётся на контексте, переживающем обрыв соединения (см. Accept),
|
||||
// поэтому собственный дедлайн обязателен: без него отказ базы держал бы
|
||||
// обработчик неограниченно.
|
||||
const recordTimeout = 10 * time.Second
|
||||
|
||||
// Service принимает пакеты: сохраняет тело в архив, учитывает доставку и
|
||||
// запускает её свёртку.
|
||||
// Notify — «есть работа»: сигнал тому, кто сворачивает принятое.
|
||||
//
|
||||
// Функцией, а не интерфейсом: сигнал ничего не несёт и ничего не возвращает,
|
||||
// а приёму незачем знать, кто именно свернёт доставку.
|
||||
type Notify func()
|
||||
|
||||
// Service принимает пакеты: сохраняет тело в архив, учитывает доставку и будит
|
||||
// свёртку.
|
||||
//
|
||||
// Сворачивать сам он не умеет намеренно. Свёртка широкой доставки идёт
|
||||
// секундами, а `WriteTimeout` в Go ставится до вызова обработчика — то есть
|
||||
// синхронная свёртка тратила бы бюджет ответа и обрывала бы соединение молча,
|
||||
// с записью `status_code=200` в журнале доступа.
|
||||
type Service struct {
|
||||
arch *archive.Archive
|
||||
store *store.Store
|
||||
fold *fold.Service
|
||||
log *slog.Logger
|
||||
arch *archive.Archive
|
||||
store *store.Store
|
||||
notify Notify
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New собирает use-case приёма.
|
||||
func New(arch *archive.Archive, st *store.Store, f *fold.Service, log *slog.Logger) *Service {
|
||||
return &Service{arch: arch, store: st, fold: f, log: log.With("capability", "ingest")}
|
||||
//
|
||||
// Нулевой notify означает «о свёртке заботится вызывающий» и приводится к
|
||||
// пустой функции здесь же, один раз: проверка на nil в месте вызова рано или
|
||||
// поздно окажется забытой, а паника там наступила бы ПОСЛЕ того, как тело уже
|
||||
// записано и доставка учтена, — то есть отправитель получил бы отказ по
|
||||
// сохранённой доставке.
|
||||
func New(arch *archive.Archive, st *store.Store, notify Notify, log *slog.Logger) *Service {
|
||||
if notify == nil {
|
||||
notify = func() {}
|
||||
}
|
||||
return &Service{arch: arch, store: st, notify: notify, log: log.With("capability", "ingest")}
|
||||
}
|
||||
|
||||
// Accept принимает тело пакета: проверяет форму, кладёт в сырой архив и
|
||||
// заводит запись о доставке.
|
||||
// Accept принимает тело пакета: проверяет форму, кладёт в сырой архив, заводит
|
||||
// запись о доставке и будит свёртку.
|
||||
//
|
||||
// Порядок важен: сначала тело оказывается на диске, и только потом появляется
|
||||
// учётная запись. Обратный порядок дал бы учтённую доставку без данных.
|
||||
//
|
||||
// Возврат означает «сохранено и учтено», а не «разобрано»: доставка уезжает в
|
||||
// очередь свёртки статусом `pending`, и её исход появится позже.
|
||||
//
|
||||
// Это единственный логирующий чекпоинт приёма — транспорт исход не логирует.
|
||||
func (s *Service) Accept(ctx context.Context, body []byte, meta Meta) (Result, error) {
|
||||
if err := checkEnvelope(body); err != nil {
|
||||
@@ -94,9 +116,14 @@ func (s *Service) Accept(ctx context.Context, body []byte, meta Meta) (Result, e
|
||||
// к часам. Источник обязан быть один: у тела, лежащего в архиве без учётной
|
||||
// записи, метку восстанавливают из ULID, и два разных источника разошлись бы
|
||||
// на границе секунды — а от порядка журнала зависит наследование слоя.
|
||||
// Ошибка здесь означает, что наш же генератор выдал неразбираемый
|
||||
// идентификатор. Второго источника времени тут быть не может — он разошёлся
|
||||
// бы с меткой, которую пересборка восстанавливает из ULID; поэтому отказ, а
|
||||
// не подмена. Тело на диск ещё не легло, так что доставка не теряется.
|
||||
receivedAt, err := ident.TimeOf(res.DeliveryID)
|
||||
if err != nil {
|
||||
receivedAt = store.Now()
|
||||
s.log.ErrorContext(ctx, "delivery failed", "error", err, "delivery_id", res.DeliveryID)
|
||||
return Result{}, fmt.Errorf("метка приёма из идентификатора: %w", err)
|
||||
}
|
||||
|
||||
rawPath, err := s.arch.Write(res.DeliveryID, receivedAt, body)
|
||||
@@ -106,7 +133,15 @@ func (s *Service) Accept(ctx context.Context, body []byte, meta Meta) (Result, e
|
||||
}
|
||||
res.RawPath = rawPath
|
||||
|
||||
err = s.store.CreateDelivery(ctx, store.Delivery{
|
||||
// Учёт ведётся на контексте, ПЕРЕЖИВАЮЩЕМ обрыв соединения. Тело к этому
|
||||
// моменту уже на диске (arch.Write контекста не берёт), и отказ вставки
|
||||
// из-за ушедшего клиента оставил бы тело сиротой: доставки в журнале нет,
|
||||
// а вернуть её может только пересборка с ручной подменой базы. Проверка
|
||||
// формы выше остаётся отменяемой — там отмена уместна.
|
||||
recordCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), recordTimeout)
|
||||
defer cancel()
|
||||
|
||||
err = s.store.CreateDelivery(recordCtx, store.Delivery{
|
||||
ID: res.DeliveryID,
|
||||
ReceivedAt: receivedAt,
|
||||
Headers: encodeHeaders(meta.Headers),
|
||||
@@ -121,8 +156,8 @@ func (s *Service) Accept(ctx context.Context, body []byte, meta Meta) (Result, e
|
||||
ParseStatus: store.ParsePending,
|
||||
})
|
||||
if err != nil {
|
||||
// Тело уже на диске — данные не потеряны, но учёта нет. Разбор архива
|
||||
// на следующем шаге проекта такую доставку подберёт.
|
||||
// Тело уже на диске — данные не потеряны, но учёта нет. Такое тело
|
||||
// подберёт пересборка (`healthlog reindex`), заведя запись заново.
|
||||
s.log.ErrorContext(ctx, "delivery failed", "error", err, "delivery_id", res.DeliveryID, "raw_path", rawPath)
|
||||
return Result{}, fmt.Errorf("record delivery: %w", err)
|
||||
}
|
||||
@@ -131,24 +166,37 @@ func (s *Service) Accept(ctx context.Context, body []byte, meta Meta) (Result, e
|
||||
"delivery_id", res.DeliveryID,
|
||||
"bytes", res.Bytes,
|
||||
"raw_path", rawPath,
|
||||
"automation_name", meta.AutomationName,
|
||||
"aggregation", meta.Aggregation,
|
||||
"period", meta.Period)
|
||||
"automation_name", clip(meta.AutomationName),
|
||||
"aggregation", clip(meta.Aggregation),
|
||||
"period", clip(meta.Period))
|
||||
|
||||
// Свёртка идёт после того, как доставка учтена, и на контексте, ОТВЯЗАННОМ
|
||||
// от запроса: обрыв соединения клиентом или прокси на середине оставил бы
|
||||
// часть объектов записанной, а доставку — со статусом, по которому её
|
||||
// никто не подберёт. Исход свёртки на код ответа не влияет — сохранили
|
||||
// значит приняли.
|
||||
foldCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), foldTimeout)
|
||||
defer cancel()
|
||||
// Ошибку не возвращаем: она уже записана в лог и в parse_status свёрткой,
|
||||
// а доставка принята.
|
||||
_, _ = s.fold.Fold(foldCtx, res.DeliveryID)
|
||||
// Сигнал идёт последним — после того, как строка учёта закоммичена: иначе
|
||||
// воркер мог бы проснуться раньше, чем увидит доставку, и потратить проход
|
||||
// впустую. Потеря сигнала отказом не является: доставка числится `pending`,
|
||||
// и её подберёт следующий сигнал, тик воркера или старт сервиса.
|
||||
s.notify()
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// maxAttrLen — сколько байт значения заголовка попадает в лог.
|
||||
//
|
||||
// Заголовки контролирует отправитель целиком, а `MaxHeaderBytes` у Go — мегабайт
|
||||
// на запрос: без границы одна доставка выдавливает из ротации логов всю недавнюю
|
||||
// историю, включая записи, по которым эту же доставку потом разыскивают. Та же
|
||||
// граница по той же причине стоит на именах метрик и секций.
|
||||
const maxAttrLen = 128
|
||||
|
||||
// clip обрезает значение, пришедшее от отправителя, до пригодного для лога.
|
||||
func clip(s string) string {
|
||||
if len(s) <= maxAttrLen {
|
||||
return s
|
||||
}
|
||||
// Обрезка названа в самом значении: молча укороченное имя автоматизации
|
||||
// выглядит как другое имя.
|
||||
return s[:maxAttrLen] + "…(обрезано)"
|
||||
}
|
||||
|
||||
// encodeHeaders сериализует заголовки для хранения. Ключи json.Marshal
|
||||
// сортирует сам, поэтому запись стабильна и её удобно сравнивать между
|
||||
// доставками. Сбой сериализации не должен ронять приём: заголовки —
|
||||
|
||||
Reference in New Issue
Block a user