- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`; миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки с этими ключами - сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА — «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина расходилась бы с пересборкой молча - отпечаток витрины покрывает тренировки и записи и снимается одним снимком базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
172 lines
7.1 KiB
Go
172 lines
7.1 KiB
Go
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 len(res.Uncovered) == 0 {
|
||
t.Fatal("непокрытых секций нет — замер идёт мимо проглатывания и ничего не сторожит")
|
||
}
|
||
|
||
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}`)
|
||
// Секция обязана быть ЗАВЕДОМО НЕПОКРЫТОЙ: тело из покрытой секции идёт
|
||
// мимо проглатывания, и замер вырождается в ноль, оставаясь зелёным.
|
||
// Так уже случилось однажды: здесь стоял `stateOfMind`, и задача, покрывшая
|
||
// его разбором, обезоружила сторож молча.
|
||
b.WriteString(`]}],"ecg":[`)
|
||
|
||
const entry = `{"id":"00000000-0000-0000-0000-000000000000","classification":"sinusRhythm"},`
|
||
for b.Len() < size {
|
||
b.WriteString(entry)
|
||
}
|
||
b.WriteString(`{"id":"tail"}]}}`)
|
||
return []byte(b.String())
|
||
}
|