Files
healthlog/internal/hae/mem_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

161 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())
}