Приём отвечает 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
|
||||
// сортирует сам, поэтому запись стабильна и её удобно сравнивать между
|
||||
// доставками. Сбой сериализации не должен ронять приём: заголовки —
|
||||
|
||||
+100
-45
@@ -12,7 +12,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/archive"
|
||||
"git.vakhrushev.me/av/healthlog/internal/fold"
|
||||
"git.vakhrushev.me/av/healthlog/internal/ingest"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
@@ -123,32 +122,9 @@ func TestAcceptRejectsMalformed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Разбор не влияет на исход приёма: сохранили — значит приняли. Непонятое
|
||||
// содержимое даёт принятую доставку с parse_status=failed, а не отказ.
|
||||
func TestAcceptНепонятоеСодержимоеПринимается(t *testing.T) {
|
||||
svc, _, st := newService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Метрика есть, но слой определить нечем: плотных метрик нет, заголовок
|
||||
// ничего не означает, наследовать не от чего.
|
||||
body := []byte(`{"data":{"metrics":[{"name":"m","units":"u","data":[` +
|
||||
`{"date":"2026-07-31 12:00:00 +0300","qty":1}]}]}}`)
|
||||
|
||||
if _, err := svc.Accept(ctx, body, ingest.Meta{Aggregation: "Default"}); err != nil {
|
||||
t.Fatalf("Accept отверг доставку из-за разбора: %v", err)
|
||||
}
|
||||
|
||||
d, err := st.LastDelivery(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("LastDelivery: %v", err)
|
||||
}
|
||||
if d.ParseStatus != store.ParseFailed {
|
||||
t.Errorf("parse_status = %q, ожидался %q", d.ParseStatus, store.ParseFailed)
|
||||
}
|
||||
}
|
||||
|
||||
// Разобранная доставка отмечается разобранной, и точки доезжают до объектов.
|
||||
func TestAcceptРазобраннаяДоставкаОтмечена(t *testing.T) {
|
||||
// Ответ отдаётся ДО свёртки: принятая доставка ждёт разбора в очереди, а не
|
||||
// приезжает разобранной. Это смена контракта, и она проверяется явно.
|
||||
func TestAcceptОставляетДоставкуВОчереди(t *testing.T) {
|
||||
svc, _, st := newService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -165,32 +141,106 @@ func TestAcceptРазобраннаяДоставкаОтмечена(t *testing
|
||||
if err != nil {
|
||||
t.Fatalf("LastDelivery: %v", err)
|
||||
}
|
||||
if d.ParseStatus != store.ParseDone {
|
||||
t.Fatalf("parse_status = %q, ожидался %q", d.ParseStatus, store.ParseDone)
|
||||
}
|
||||
if d.Points == 0 {
|
||||
t.Error("точек 0: разбор не дошёл до учёта")
|
||||
if d.ParseStatus != store.ParsePending {
|
||||
t.Errorf("parse_status = %q, ожидался %q", d.ParseStatus, store.ParsePending)
|
||||
}
|
||||
|
||||
n, err := st.CountBuckets(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CountBuckets: %v", err)
|
||||
}
|
||||
if n == 0 {
|
||||
t.Error("объектов 0: точки не доехали до хранилища")
|
||||
if n != 0 {
|
||||
t.Errorf("объектов %d: свёртка произошла внутри приёма", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Свёртка идёт на контексте, отвязанном от запроса (context.WithoutCancel в
|
||||
// Accept), чтобы обрыв соединения не оставил часть объектов записанной.
|
||||
// Автотестом это не покрыто: отмену надо подать РОВНО между учётом доставки и
|
||||
// свёрткой, а такого шва снаружи нет, и заводить его ради теста дороже, чем
|
||||
// проверять глазами. Атомарность самой записи проверена в store
|
||||
// (TestMergePointsОтменаНеОставляетПоловины).
|
||||
|
||||
func newService(t *testing.T) (*ingest.Service, *archive.Archive, *store.Store) {
|
||||
t.Helper()
|
||||
// Сигнал уходит после того, как доставка учтена: воркер, разбуженный раньше,
|
||||
// потратил бы проход впустую.
|
||||
func TestAcceptБудитСвёрткуПослеУчёта(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, arch := newDeps(t, dir)
|
||||
|
||||
var seen int64
|
||||
notify := func() {
|
||||
n, err := st.CountDeliveries(context.Background())
|
||||
if err != nil {
|
||||
t.Errorf("CountDeliveries: %v", err)
|
||||
}
|
||||
seen = n
|
||||
}
|
||||
svc := ingest.New(arch, st, notify, slog.New(slog.DiscardHandler))
|
||||
|
||||
if _, err := svc.Accept(context.Background(), []byte(`{"data":{"metrics":[]}}`), ingest.Meta{}); err != nil {
|
||||
t.Fatalf("Accept: %v", err)
|
||||
}
|
||||
if seen != 1 {
|
||||
t.Errorf("на момент сигнала доставок в учёте %d, ожидалась 1", seen)
|
||||
}
|
||||
}
|
||||
|
||||
// Нулевой сигнал — законный вход (свёрткой заведует вызывающий), и приём от
|
||||
// него не падает. Паника здесь наступила бы ПОСЛЕ записи тела и учёта, то есть
|
||||
// отправитель получил бы отказ по сохранённой доставке.
|
||||
func TestAcceptБезСигналаНеПадает(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, arch := newDeps(t, dir)
|
||||
svc := ingest.New(arch, st, nil, slog.New(slog.DiscardHandler))
|
||||
|
||||
if _, err := svc.Accept(context.Background(), []byte(`{"data":{"metrics":[]}}`), ingest.Meta{}); err != nil {
|
||||
t.Fatalf("Accept: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Обрыв соединения после записи тела не должен оставлять тело без учёта:
|
||||
// доставка, не попавшая в журнал, восстанавливается только пересборкой с
|
||||
// ручной подменой базы.
|
||||
func TestAcceptУчитываетДоставкуПослеОбрываСоединения(t *testing.T) {
|
||||
svc, _, st := newService(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
res, err := svc.Accept(ctx, []byte(`{"data":{"metrics":[]}}`), ingest.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("Accept на отменённом контексте: %v", err)
|
||||
}
|
||||
|
||||
status, err := st.DeliveryStatus(context.Background(), res.DeliveryID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeliveryStatus: %v", err)
|
||||
}
|
||||
if status != store.ParsePending {
|
||||
t.Errorf("parse_status = %q, ожидался %q", status, store.ParsePending)
|
||||
}
|
||||
}
|
||||
|
||||
// Учёта нет, а тело есть: приём кладёт тело на диск раньше строки в базе, и
|
||||
// отказ на вставке оставляет тело в архиве. Такое тело подберёт пересборка.
|
||||
func TestAcceptПриОтказеУчётаОставляетТелоВАрхиве(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, arch := newDeps(t, dir)
|
||||
svc := ingest.New(arch, st, nil, slog.New(slog.DiscardHandler))
|
||||
|
||||
// База закрыта — учесть доставку нечем.
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("закрытие базы: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.Accept(context.Background(), []byte(`{"data":{"metrics":[]}}`), ingest.Meta{}); err == nil {
|
||||
t.Fatal("приём не заметил, что доставка не учтена")
|
||||
}
|
||||
|
||||
entries, err := filepath.Glob(filepath.Join(arch.Root(), "*", "*", "*", "*.json.gz"))
|
||||
if err != nil {
|
||||
t.Fatalf("обход архива: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("тел в архиве %d, ожидалось 1: тело потеряно вместе с учётом", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func newDeps(t *testing.T, dir string) (*store.Store, *archive.Archive) {
|
||||
t.Helper()
|
||||
|
||||
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
|
||||
if err != nil {
|
||||
@@ -202,7 +252,12 @@ func newService(t *testing.T) (*ingest.Service, *archive.Archive, *store.Store)
|
||||
if err != nil {
|
||||
t.Fatalf("archive.New: %v", err)
|
||||
}
|
||||
return st, arch
|
||||
}
|
||||
|
||||
log := slog.New(slog.DiscardHandler)
|
||||
return ingest.New(arch, st, fold.New(arch, st, 0, log), log), arch, st
|
||||
func newService(t *testing.T) (*ingest.Service, *archive.Archive, *store.Store) {
|
||||
t.Helper()
|
||||
|
||||
st, arch := newDeps(t, t.TempDir())
|
||||
return ingest.New(arch, st, nil, slog.New(slog.DiscardHandler)), arch, st
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user