свёртка доставки в объекты и сшивка с приёмом

- internal/fold — свёртка по идентификатору доставки, тело из архива: тот же
  код, каким пойдёт пересборка витрины
- приём зовёт свёртку на context.WithoutCancel с собственным дедлайном;
  исход разбора на код ответа не влияет
- слой доставки хранится в delivery.derived_layer и наследуется от
  ПРЕДШЕСТВУЮЩЕЙ доставки автоматизации: без границы по времени свёртка
  переставала быть функцией от префикса журнала (1737 объектов против 1742)
- task verify:archive — сходимость на живом архиве, 99 доставок из 99
This commit is contained in:
av
2026-08-01 18:03:59 +03:00
parent c9ddf0b4dd
commit 32f21044e4
17 changed files with 898 additions and 36 deletions
+25 -3
View File
@@ -11,8 +11,10 @@ import (
"errors"
"fmt"
"log/slog"
"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"
)
@@ -46,16 +48,25 @@ type Result struct {
RawPath string
}
// Service принимает пакеты: сохраняет тело в архив и учитывает доставку.
// foldTimeout — сколько отводится свёртке принятой доставки.
//
// Свёртка идёт на контексте, отвязанном от запроса, поэтому собственный
// дедлайн обязателен: без него зависшая запись держала бы горутину до конца
// жизни процесса.
const foldTimeout = 2 * time.Minute
// Service принимает пакеты: сохраняет тело в архив, учитывает доставку и
// запускает её свёртку.
type Service struct {
arch *archive.Archive
store *store.Store
fold *fold.Service
log *slog.Logger
}
// New собирает use-case приёма.
func New(arch *archive.Archive, st *store.Store, log *slog.Logger) *Service {
return &Service{arch: arch, store: st, log: log.With("capability", "ingest")}
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")}
}
// Accept принимает тело пакета: проверяет форму, кладёт в сырой архив и
@@ -117,6 +128,17 @@ func (s *Service) Accept(ctx context.Context, body []byte, meta Meta) (Result, e
"aggregation", meta.Aggregation,
"period", meta.Period)
// Свёртка идёт после того, как доставка учтена, и на контексте, ОТВЯЗАННОМ
// от запроса: обрыв соединения клиентом или прокси на середине оставил бы
// часть объектов записанной, а доставку — со статусом, по которому её
// никто не подберёт. Исход свёртки на код ответа не влияет — сохранили
// значит приняли.
foldCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), foldTimeout)
defer cancel()
// Ошибку не возвращаем: она уже записана в лог и в parse_status свёрткой,
// а доставка принята.
_, _ = s.fold.Fold(foldCtx, res.DeliveryID)
return res, nil
}
+68 -1
View File
@@ -7,10 +7,12 @@ import (
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"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"
)
@@ -121,6 +123,71 @@ 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) {
svc, _, st := newService(t)
ctx := context.Background()
body, err := os.ReadFile(filepath.Join("..", "hae", "testdata", "minute.json"))
if err != nil {
t.Fatalf("фикстура: %v", err)
}
if _, err := svc.Accept(ctx, body, ingest.Meta{Aggregation: "Minutes"}); err != nil {
t.Fatalf("Accept: %v", err)
}
d, err := st.LastDelivery(ctx)
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: разбор не дошёл до учёта")
}
n, err := st.CountBuckets(ctx)
if err != nil {
t.Fatalf("CountBuckets: %v", err)
}
if n == 0 {
t.Error("объектов 0: точки не доехали до хранилища")
}
}
// Свёртка идёт на контексте, отвязанном от запроса (context.WithoutCancel в
// Accept), чтобы обрыв соединения не оставил часть объектов записанной.
// Автотестом это не покрыто: отмену надо подать РОВНО между учётом доставки и
// свёрткой, а такого шва снаружи нет, и заводить его ради теста дороже, чем
// проверять глазами. Атомарность самой записи проверена в store
// (TestMergePointsОтменаНеОставляетПоловины).
func newService(t *testing.T) (*ingest.Service, *archive.Archive, *store.Store) {
t.Helper()
dir := t.TempDir()
@@ -137,5 +204,5 @@ func newService(t *testing.T) (*ingest.Service, *archive.Archive, *store.Store)
}
log := slog.New(slog.DiscardHandler)
return ingest.New(arch, st, log), arch, st
return ingest.New(arch, st, fold.New(arch, st, log), log), arch, st
}