Приём отвечает 200 до свёртки, свёртку ведёт фоновый воркер

- Очередью служит сама таблица: доставка ждёт свёртки в статусе `pending`,
  канал несёт только бит «есть работа». Переполнять нечего, падение процесса
  очередь не теряет, а подбор `pending` при старте — обычный проход воркера, а
  не отдельный код. Классификация исхода общая с пересборкой журнала.
- Исход разбора начал отражать доставку, а не обстоятельства: отмена и
  занятость базы статус не меняют (иначе конкуренция за базу выводила бы
  доставку из очереди навсегда), паника свёртки больше не валит процесс, а
  учёт доставки идёт через транзакцию с повторами.
- Длинный бюджет ответа выдан маршруту приёма, а не всему серверу:
  `write_timeout` в Go покрывает и чтение тела, и общий подъём снял бы защиту с
  остальных маршрутов.
This commit is contained in:
av
2026-08-02 11:01:42 +03:00
parent ebd59af056
commit 63bffe2865
46 changed files with 3561 additions and 296 deletions
+81 -33
View File
@@ -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
View File
@@ -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
}