Триаж свёл 62 сырые находки девяти проходов к 33 причинам: 3 блокера, 4 «сейчас», 2 развилки. Все закрыты регрессионными тестами. - схема точки сна определяется по самой точке, а не по индексу в исходном массиве: одна пропущенная точка меняла эпизод и сводку местами - доставка сворачивается одной транзакцией: частичное состояние было недетерминированным (восемь прогонов — семь состояний) - граница размера на распакованном теле: 400 КиБ gzip разворачивались в 400 МиБ мимо max_body_mb - столкновение — расхождение канонических форм, а не байтов; WARN с координатами объекта; payload без HTML-экранирования - выравнивание по местной метке: получасовые зоны уводили часовую выгрузку в minute - доставка из одних суточных сводок больше не отвергается целиком - единицы не переписываются молча; счётчик считает сохранённые точки - все выходы Fold логируются, исход пишется на переживающем отмену контексте Четыре развилки вынесены блокерами в беклог.
231 lines
7.7 KiB
Go
231 lines
7.7 KiB
Go
package fold_test
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.vakhrushev.me/av/healthlog/internal/archive"
|
|
"git.vakhrushev.me/av/healthlog/internal/fold"
|
|
"git.vakhrushev.me/av/healthlog/internal/store"
|
|
)
|
|
|
|
func newFold(t *testing.T) (*fold.Service, *archive.Archive, *store.Store) {
|
|
t.Helper()
|
|
|
|
dir := t.TempDir()
|
|
arch, err := archive.New(filepath.Join(dir, "raw"))
|
|
if err != nil {
|
|
t.Fatalf("архив: %v", err)
|
|
}
|
|
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
|
|
if err != nil {
|
|
t.Fatalf("база: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = st.Close() })
|
|
|
|
log := slog.New(slog.DiscardHandler)
|
|
return fold.New(arch, st, 0, log), arch, st
|
|
}
|
|
|
|
// deliver кладёт тело в архив и заводит доставку — ровно то, что делает приём.
|
|
func deliver(t *testing.T, arch *archive.Archive, st *store.Store, id, aggregation, automationID string, body []byte) {
|
|
t.Helper()
|
|
|
|
at := store.Now()
|
|
rawPath, err := arch.Write(id, at, body)
|
|
if err != nil {
|
|
t.Fatalf("запись в архив: %v", err)
|
|
}
|
|
err = st.CreateDelivery(context.Background(), store.Delivery{
|
|
ID: id,
|
|
ReceivedAt: at,
|
|
AutomationID: automationID,
|
|
Aggregation: aggregation,
|
|
Bytes: int64(len(body)),
|
|
SHA256: "-",
|
|
RawPath: rawPath,
|
|
ParseStatus: store.ParsePending,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("запись доставки: %v", err)
|
|
}
|
|
}
|
|
|
|
func fixture(t *testing.T, name string) []byte {
|
|
t.Helper()
|
|
|
|
body, err := os.ReadFile(filepath.Join("..", "hae", "testdata", name))
|
|
if err != nil {
|
|
t.Fatalf("фикстура %s: %v", name, err)
|
|
}
|
|
return body
|
|
}
|
|
|
|
// Свёртка читает тело из архива по идентификатору доставки — тем же кодом,
|
|
// каким его прочитает пересборка витрины.
|
|
func TestFoldРаскладываетТочкиПоОбъектам(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f, arch, st := newFold(t)
|
|
ctx := context.Background()
|
|
|
|
deliver(t, arch, st, "d1", "Minutes", "auto-1", fixture(t, "minute.json"))
|
|
|
|
stats, err := f.Fold(ctx, "d1")
|
|
if err != nil {
|
|
t.Fatalf("свёртка: %v", err)
|
|
}
|
|
if stats.Points == 0 || stats.Buckets == 0 {
|
|
t.Fatalf("свёртка ничего не разложила: %+v", stats)
|
|
}
|
|
if stats.Layer != "minute" {
|
|
t.Errorf("слой доставки %q, ожидался minute", stats.Layer)
|
|
}
|
|
|
|
n, err := st.CountBuckets(ctx)
|
|
if err != nil {
|
|
t.Fatalf("счёт объектов: %v", err)
|
|
}
|
|
if n != int64(stats.Buckets) {
|
|
t.Errorf("объектов в базе %d, свёртка насчитала %d", n, stats.Buckets)
|
|
}
|
|
}
|
|
|
|
// Слой сохраняется, чтобы его унаследовала следующая доставка той же
|
|
// автоматизации: без плотных метрик выводить его не из чего, а после
|
|
// перезапуска сервиса память об этом иначе теряется.
|
|
func TestFoldСлойНаследуетсяВнутриАвтоматизации(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f, arch, st := newFold(t)
|
|
ctx := context.Background()
|
|
|
|
deliver(t, arch, st, "d1", "Minutes", "auto-1", fixture(t, "minute.json"))
|
|
if _, err := f.Fold(ctx, "d1"); err != nil {
|
|
t.Fatalf("первая свёртка: %v", err)
|
|
}
|
|
|
|
// Доставка без плотных метрик и с бесполезным заголовком: сама по себе
|
|
// слой не выводится.
|
|
deliver(t, arch, st, "d2", "Default", "auto-1", fixture(t, "sparse_sleep.json"))
|
|
stats, err := f.Fold(ctx, "d2")
|
|
if err != nil {
|
|
t.Fatalf("вторая свёртка: %v", err)
|
|
}
|
|
if stats.Layer != "minute" {
|
|
t.Errorf("слой %q, ожидался унаследованный minute", stats.Layer)
|
|
}
|
|
}
|
|
|
|
// Наследовать нечего, заголовок ненадёжен: точки не сохраняются, доставка
|
|
// помечается неразобранной и ждёт пересборки. Молчаливый выбор raw создал бы
|
|
// призрачный разрез.
|
|
func TestFoldНеопределимыйСлойНеПишетТочек(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f, arch, st := newFold(t)
|
|
ctx := context.Background()
|
|
|
|
deliver(t, arch, st, "d1", "Default", "auto-1", fixture(t, "sparse_sleep.json"))
|
|
|
|
if _, err := f.Fold(ctx, "d1"); err == nil {
|
|
t.Fatal("свёртка с неопределимым слоем прошла успешно")
|
|
}
|
|
|
|
n, err := st.CountBuckets(ctx)
|
|
if err != nil {
|
|
t.Fatalf("счёт объектов: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("записано %d объектов при неизвестном слое", n)
|
|
}
|
|
}
|
|
|
|
// Повторная свёртка той же доставки не меняет состояния: журнал можно
|
|
// проигрывать сколько угодно раз.
|
|
func TestFoldИдемпотентен(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f, arch, st := newFold(t)
|
|
ctx := context.Background()
|
|
|
|
deliver(t, arch, st, "d1", "Minutes", "auto-1", fixture(t, "minute.json"))
|
|
|
|
first, err := f.Fold(ctx, "d1")
|
|
if err != nil {
|
|
t.Fatalf("первая свёртка: %v", err)
|
|
}
|
|
second, err := f.Fold(ctx, "d1")
|
|
if err != nil {
|
|
t.Fatalf("повторная свёртка: %v", err)
|
|
}
|
|
|
|
if second.Unchanged != second.Buckets {
|
|
t.Errorf("изменилось %d объектов из %d при повторе того же тела",
|
|
second.Buckets-second.Unchanged, second.Buckets)
|
|
}
|
|
if first.Buckets != second.Buckets {
|
|
t.Errorf("объектов %d против %d", first.Buckets, second.Buckets)
|
|
}
|
|
}
|
|
|
|
// Порядок воспроизведения доставок не влияет на итог: свёртка по журналу
|
|
// обязана давать то же состояние, что приём в реальном времени.
|
|
func TestFoldНеЗависитОтПорядкаДоставок(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
state := func(order []string) (int64, string) {
|
|
f, arch, st := newFold(t)
|
|
ctx := context.Background()
|
|
|
|
bodies := map[string]string{"a": "minute.json", "b": "hour.json", "c": "raw.json"}
|
|
aggs := map[string]string{"a": "Minutes", "b": "Hours", "c": "Default"}
|
|
for _, id := range order {
|
|
deliver(t, arch, st, id, aggs[id], "auto-"+id, fixture(t, bodies[id]))
|
|
if _, err := f.Fold(ctx, id); err != nil {
|
|
t.Fatalf("свёртка %s: %v", id, err)
|
|
}
|
|
}
|
|
|
|
n, err := st.CountBuckets(ctx)
|
|
if err != nil {
|
|
t.Fatalf("счёт объектов: %v", err)
|
|
}
|
|
b, err := st.Bucket(ctx, "active_energy", "minute", mustHour(t, st, "active_energy", "minute"))
|
|
if err != nil {
|
|
return n, ""
|
|
}
|
|
return n, b.Hash
|
|
}
|
|
|
|
nForward, hForward := state([]string{"a", "b", "c"})
|
|
nBackward, hBackward := state([]string{"c", "b", "a"})
|
|
|
|
if nForward != nBackward {
|
|
t.Errorf("объектов %d против %d при разном порядке доставок", nForward, nBackward)
|
|
}
|
|
if hForward != hBackward {
|
|
t.Errorf("содержимое разошлось: %s против %s", hForward, hBackward)
|
|
}
|
|
}
|
|
|
|
// mustHour находит час, в котором лежит объект метрики: фикстуры сдвинуты по
|
|
// времени, и зашивать конкретную дату в тест значило бы ломать его при каждой
|
|
// пересборке набора.
|
|
func mustHour(t *testing.T, st *store.Store, metric, layer string) time.Time {
|
|
t.Helper()
|
|
|
|
hours, err := st.BucketHours(context.Background(), metric, layer)
|
|
if err != nil {
|
|
t.Fatalf("часы объекта: %v", err)
|
|
}
|
|
if len(hours) == 0 {
|
|
t.Fatalf("объектов метрики %s слоя %s нет", metric, layer)
|
|
}
|
|
return hours[0]
|
|
}
|