Приём отвечает 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
+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
}