Files
healthlog/internal/fold/fold_test.go
T
av 34e5109b6d непокрытые секции доставки видны в статусе разбора
- половина потока (50 доставок из 104) не несёт metrics вовсе и до сих пор
  числилась parsed: ретеншен, поверив статусу, срезал бы тела stateOfMind,
  которых в экспорте Apple нет
- разбор перечисляет верхнеуровневые ключи data, непокрытые проглатываются
  декодированием: тело 40 МиБ из непокрытой секции удерживает 0 МиБ
- статус partial и колонка delivery.uncovered_sections; миграция переводит
  прежние parsed в pending — им верить нельзя
- витрина не изменилась: отпечаток совпал с прогоном до изменения
2026-08-01 21:25:59 +03:00

327 lines
12 KiB
Go

package fold_test
import (
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"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]
}
// Доставка с непокрытой секцией обязана быть ОТЛИЧИМА от разобранной целиком.
// Без этого ретеншен, ориентируясь на статус, срежет тело — а для stateOfMind
// это необратимо: в экспорте Apple секции нет, доставки HAE единственный
// источник.
func TestFoldЧастичныйРазборВиденВУчёте(t *testing.T) {
t.Parallel()
f, arch, st := newFold(t)
ctx := context.Background()
deliver(t, arch, st, "d1", "Minutes", "a1", fixture(t, "uncovered_sections.json"))
stats, err := f.Fold(ctx, "d1")
if err != nil {
t.Fatalf("свёртка: %v", err)
}
if len(stats.Uncovered) == 0 {
t.Error("список непокрытых секций пуст")
}
d, err := st.LastDelivery(ctx)
if err != nil {
t.Fatalf("чтение доставки: %v", err)
}
if d.ParseStatus != store.ParsePartial {
t.Errorf("статус %q, ожидался %q", d.ParseStatus, store.ParsePartial)
}
if !strings.Contains(d.UncoveredSections, "stateOfMind") {
t.Errorf("список в базе %q не содержит stateOfMind", d.UncoveredSections)
}
// Точки метрик обязаны сохраниться: частичность не отменяет разобранного.
if stats.Points == 0 {
t.Error("точек нет — покрытая секция потерялась вместе с непокрытой")
}
}
// Доставка из одних метрик списка не получает и остаётся parsed: частичность
// производна от списка, а не назначена.
func TestFoldПолныйРазборОстаётсяParsed(t *testing.T) {
t.Parallel()
f, arch, st := newFold(t)
ctx := context.Background()
deliver(t, arch, st, "d1", "Minutes", "a1", fixture(t, "minute.json"))
if _, err := f.Fold(ctx, "d1"); err != nil {
t.Fatalf("свёртка: %v", err)
}
d, err := st.LastDelivery(ctx)
if err != nil {
t.Fatalf("чтение доставки: %v", err)
}
if d.ParseStatus != store.ParseDone {
t.Errorf("статус %q, ожидался %q", d.ParseStatus, store.ParseDone)
}
if d.UncoveredSections != "[]" {
t.Errorf("список %q, ожидался `[]` — ровно одно представление пустоты", d.UncoveredSections)
}
}
// Список замещает прежнее значение ЦЕЛИКОМ, включая замещение пустым. Иначе
// доставка, чья секция стала покрытой, осталась бы partial навсегда, и
// ретеншен вечно щадил бы тело, которое уже не нужно.
func TestFoldПересвёрткаОчищаетСписок(t *testing.T) {
t.Parallel()
f, arch, st := newFold(t)
ctx := context.Background()
// Сперва доставка с непокрытой секцией.
deliver(t, arch, st, "d1", "Minutes", "a1", fixture(t, "uncovered_sections.json"))
if _, err := f.Fold(ctx, "d1"); err != nil {
t.Fatalf("свёртка: %v", err)
}
// Затем — та же доставка, но тело уже без непокрытых секций: так выглядит
// пересвёртка после того, как секцию научились разбирать.
deliver(t, arch, st, "d2", "Minutes", "a1", fixture(t, "minute.json"))
if _, err := f.Fold(ctx, "d2"); err != nil {
t.Fatalf("свёртка: %v", err)
}
d, err := st.LastDelivery(ctx)
if err != nil {
t.Fatalf("чтение доставки: %v", err)
}
if d.UncoveredSections != "[]" {
t.Errorf("список %q не очистился — пустой список обязан замещать прежний", d.UncoveredSections)
}
if d.ParseStatus != store.ParseDone {
t.Errorf("статус %q, ожидался %q", d.ParseStatus, store.ParseDone)
}
}