Files
av f8200f7f80 feat: разбор и хранение тренировок и состояния разума
- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной
  строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`;
  миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки
  с этими ключами
- сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет
  содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а
  при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА —
  «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина
  расходилась бы с пересборкой молча
- отпечаток витрины покрывает тренировки и записи и снимается одним снимком
  базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
2026-08-02 13:05:16 +03:00

172 lines
7.1 KiB
Go
Raw Permalink 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 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())
}