package hae_test import ( "fmt" "runtime" "strings" "testing" "time" "git.vakhrushev.me/av/healthlog/internal/hae" ) // Тела доходили до 42 МиБ, и стратегия декодирования — часть контракта, а не // деталь: разбор такого тела в map[string]any удерживает 197 МиБ кучи против // 54 МиБ у формы с json.RawMessage. Вместе с самим телом и удвоением в чтении // пик доходил бы до ~300 МиБ на доставку — это OOM ровно на пике потока, когда // терять доставки дороже всего. // // Тест сторожит именно порядок величины, а не точное число: он обязан падать // при возврате к map[string]any и не падать от смены версии Go. func TestParseУдержаниеКучи(t *testing.T) { if testing.Short() { t.Skip("измерение кучи: не для -short") } body := largeBody(40, 4000) t.Logf("тело %d МиБ", len(body)>>20) var before, after runtime.MemStats runtime.GC() runtime.ReadMemStats(&before) res, err := hae.Parse(body, hae.Meta{Aggregation: "Minutes"}) if err != nil { t.Fatalf("разбор: %v", err) } runtime.GC() runtime.ReadMemStats(&after) retained := int64(after.HeapAlloc) - int64(before.HeapAlloc) limit := int64(len(body)) * 4 t.Logf("точек %d, удержано %d МиБ при теле %d МиБ", len(res.Points), retained>>20, len(body)>>20) if retained > limit { t.Errorf("удержано %d МиБ при теле %d МиБ — больше четырёх тел; "+ "похоже, точки снова разбираются в map[string]any", retained>>20, len(body)>>20) } // Результат обязан дожить до замера, иначе мерить нечего. runtime.KeepAlive(res) runtime.KeepAlive(body) } // largeBody собирает тело, похожее на настоящее: те же поля, тот же формат // метки, минутное выравнивание. func largeBody(metrics, points int) []byte { var b strings.Builder b.WriteString(`{"data":{"metrics":[`) base := time.Date(2025, 6, 5, 0, 0, 0, 0, time.FixedZone("", 3*3600)) for m := range metrics { if m > 0 { b.WriteByte(',') } fmt.Fprintf(&b, `{"name":"metric_%d","units":"count","data":[`, m) for p := range points { if p > 0 { b.WriteByte(',') } ts := base.Add(time.Duration(p) * time.Minute).Format("2006-01-02 15:04:05 -0700") fmt.Fprintf(&b, `{"date":"%s","qty":%d.09523182962471353,"source":"Device A|Device B"}`, ts, p%97) } b.WriteString(`]}`) } b.WriteString(`]}}`) return []byte(b.String()) } // Перечисление непокрытых секций не имеет права стать вторым способом удержать // тело. Непокрытая секция ПРОГЛАТЫВАЕТСЯ декодированием в выбрасываемый // RawMessage: копия одна, живёт до следующего члена, удерживается ноль. Разбор // `data` в map[string]json.RawMessage дешевле по коду, но копирует байты всех // секций и держит их до конца разбора — этот тест сторожит разницу. func TestParseУдержаниеКучиНепокрытойСекции(t *testing.T) { if testing.Short() { t.Skip("измерение кучи: не для -short") } body := bodyWithUncovered(40 << 20) t.Logf("тело %d МиБ, из них непокрытая секция почти всё", len(body)>>20) var before, after runtime.MemStats runtime.GC() runtime.ReadMemStats(&before) // Слой единственной точке взяться неоткуда — наследуем, иначе разбор // откажет раньше, чем дойдёт до замера. res, err := hae.Parse(body, hae.Meta{FallbackLayer: hae.LayerMinute}) if err != nil { t.Fatalf("разбор: %v", err) } runtime.GC() runtime.ReadMemStats(&after) retained := int64(after.HeapAlloc) - int64(before.HeapAlloc) limit := int64(len(body)) * 4 t.Logf("непокрытых %v, удержано %d МиБ при теле %d МиБ", res.Uncovered, retained>>20, len(body)>>20) if retained > limit { t.Errorf("удержано %d МиБ при теле %d МиБ — больше четырёх тел; "+ "похоже, секции удерживаются, а не проглатываются", retained>>20, len(body)>>20) } runtime.KeepAlive(res) runtime.KeepAlive(body) } // Тело из вложенных скобок обязано быть ОТВЕРГНУТО, а не съедено. Пропуск // ручным счётом глубины по Token() этого не давал: делимитеры идут мимо // сканера, ограничитель вложенности encoding/json не работает, и стек токенов // растёт как O(глубины) — измерено 488 МиБ пика на теле 40 МиБ. func TestParseВложенныеСкобкиОтвергаются(t *testing.T) { t.Parallel() const depth = 100_000 var b strings.Builder b.WriteString(`{"data":{"deep":`) b.WriteString(strings.Repeat("[", depth)) b.WriteString(strings.Repeat("]", depth)) b.WriteString(`}}`) if _, err := hae.Parse([]byte(b.String()), hae.Meta{}); err == nil { t.Error("тело из ста тысяч уровней вложенности принято — ограничитель вложенности не работает") } } func bodyWithUncovered(size int) []byte { var b strings.Builder b.WriteString(`{"data":{"metrics":[{"name":"m","units":"count","data":[`) b.WriteString(`{"date":"2025-06-05 10:00:00 +0300","qty":1}`) b.WriteString(`]}],"stateOfMind":[`) const entry = `{"id":"00000000-0000-0000-0000-000000000000","kind":"momentary_emotion","valence":0.5},` for b.Len() < size { b.WriteString(entry) } b.WriteString(`{"id":"tail"}]}}`) return []byte(b.String()) }