непокрытые секции доставки видны в статусе разбора

- половина потока (50 доставок из 104) не несёт metrics вовсе и до сих пор
  числилась parsed: ретеншен, поверив статусу, срезал бы тела stateOfMind,
  которых в экспорте Apple нет
- разбор перечисляет верхнеуровневые ключи data, непокрытые проглатываются
  декодированием: тело 40 МиБ из непокрытой секции удерживает 0 МиБ
- статус partial и колонка delivery.uncovered_sections; миграция переводит
  прежние parsed в pending — им верить нельзя
- витрина не изменилась: отпечаток совпал с прогоном до изменения
This commit is contained in:
av
2026-08-01 21:25:59 +03:00
parent 7a7594e3e7
commit 34e5109b6d
26 changed files with 1808 additions and 89 deletions
+206 -12
View File
@@ -17,7 +17,9 @@ import (
"encoding/json"
"errors"
"fmt"
"sort"
"time"
"unicode/utf8"
)
// Layer — подробность, в которой метрика приехала. Выводится из выравнивания
@@ -99,6 +101,19 @@ type Point struct {
type Result struct {
Points []Point
// Uncovered — верхнеуровневые ключи `data`, которых разбор не покрывает,
// отсортированные и без повторов. Половина живого потока состоит из таких
// доставок целиком (48 из 99: workouts и stateOfMind), и без этого списка
// они неотличимы от разобранной доставки с пустой секцией метрик.
//
// Список канонизирован потому, что уезжает в базу и сравнивается между
// доставками, а порядок ключей в JSON от HAE нестабилен.
Uncovered []string
// UncoveredDropped — сколько имён отброшено границей списка. Молчаливое
// усечение сделало бы список уверенным, но неполным ответом на вопрос «что
// останется потерянным, если тело удалить».
UncoveredDropped int
// Metrics — сколько метрик встретилось в секции.
Metrics int
// SkippedNoTime — точки без разбираемой метки времени.
@@ -155,10 +170,12 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
}
}()
metrics, err := decodeMetrics(body)
metrics, uncovered, dropped, err := decodeEnvelope(body)
if err != nil {
return Result{}, err
}
res.Uncovered = uncovered
res.UncoveredDropped = dropped
res.Metrics = len(metrics)
if len(metrics) == 0 {
@@ -188,7 +205,15 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
res.HeaderLayer = headerLayer(meta.Aggregation)
if err := assignLayers(groups, meta, &res); err != nil {
return Result{Metrics: res.Metrics}, err
// Список непокрытых секций переживает отказ: доставка, у которой не
// определился слой, обязана остаться записью о том, что в теле есть
// невосстановимая секция. Иначе ретеншен увидит failed без списка и
// решит, что терять нечего.
return Result{
Metrics: res.Metrics,
Uncovered: res.Uncovered,
UncoveredDropped: res.UncoveredDropped,
}, err
}
total := 0
@@ -237,12 +262,27 @@ type group struct {
// 42 МиБ через map[string]any удерживает 197 МиБ кучи против 54 МиБ у этой
// формы. Вместе с самим телом пик доходил бы до ~300 МиБ на доставку — это
// OOM ровно на пике потока, когда терять доставки дороже всего.
type envelope struct {
Data struct {
Metrics []metricEnvelope `json:"metrics"`
} `json:"data"`
// metricsSection — единственная секция, которую разбор покрывает сегодня.
const metricsSection = "metrics"
// covered говорит, покрывает ли разбор секцию с таким именем.
//
// Функция, а не изменяемая карта: разбор и перечисление непокрытых ходят по
// одному источнику, поэтому состояние «секция разбирается, но числится
// непокрытой» невыразимо.
func covered(section string) bool {
return section == metricsSection
}
// Границы на список непокрытых ключей. Тело контролирует отправитель целиком:
// без границ сто тысяч однобуквенных ключей превращаются в одну строку в базе
// и одну строку в логе того же порядка. Секций у HAE восемь, самое длинное имя
// — heartRateNotifications (22 байта), так что запас велик.
const (
maxUncovered = 32
maxUncoveredLen = 64
)
type metricEnvelope struct {
Name string `json:"name"`
Units string `json:"units"`
@@ -261,13 +301,167 @@ type pointHead struct {
TotalSleep *json.RawMessage `json:"totalSleep"`
}
func decodeMetrics(body []byte) ([]metricEnvelope, error) {
var env envelope
dec := json.NewDecoder(bytes.NewReader(body))
if err := dec.Decode(&env); err != nil {
return nil, fmt.Errorf("%w: %v", ErrMalformed, err) //nolint:errorlint // причина уходит в лог, наружу не раскрывается
// decodeEnvelope разбирает конверт: отдаёт секцию metrics и имена секций,
// которых разбор не покрывает.
//
// Идёт по верхнему уровню одним декодером: Token() читает рамку объекта и имена
// членов, Decode() — значения. Значение покрытого ключа декодируется на месте,
// значение непокрытого ПРОГЛАТЫВАЕТСЯ декодированием в выбрасываемый
// RawMessage. Это форма из ExampleDecoder_Decode_stream стандартной библиотеки;
// в encoding/json/v2 та же операция названа прямо — SkipValue.
//
// Пропуск ручным счётом глубины по Token() выглядит дешевле и измеримо хуже:
// делимитеры идут мимо сканера, поэтому ограничитель вложенности encoding/json
// не работает, а стек токенов растёт как O(глубины). Тело 40 МиБ из вложенных
// скобок даёт пик 488 МиБ вместо контрактных четырёх тел — Decode отвергает его
// мгновенно. Разбор `data` в map[string]json.RawMessage дешевле по коду, но
// копирует байты ВСЕХ секций и держит их до конца разбора; у проглатывания
// копия одна и живёт до следующего члена.
func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string, dropped int, err error) {
fail := func(e error) ([]metricEnvelope, []string, int, error) {
return nil, nil, 0, fmt.Errorf("%w: %v", ErrMalformed, e) //nolint:errorlint // причина уходит в лог, наружу не раскрывается
}
return env.Data.Metrics, nil
dec := json.NewDecoder(bytes.NewReader(body))
// Верхний уровень тела: интересует только data. Прочие ключи конверта в
// список не идут — иначе в одном списке смешались бы имена секций и мусор
// конверта, а форму `{"data": …}` проверяет приём.
tok, err := dec.Token()
if err != nil {
return fail(err)
}
// Голый null телом ошибкой не был и не становится: прежний разбор
// раскладывал его в пустую структуру. Границы поведения этой задачей не
// двигаются — она добавляет список, а не строгость.
if tok == nil {
return nil, nil, 0, nil
}
if d, ok := tok.(json.Delim); !ok || d != '{' {
return fail(fmt.Errorf("ожидался объект, встречено %v", tok))
}
seen := make(map[string]struct{})
for dec.More() {
name, err := memberName(dec)
if err != nil {
return fail(err)
}
if name != "data" {
if err := swallow(dec); err != nil {
return fail(err)
}
continue
}
metrics, uncovered, dropped, err = decodeData(dec, seen)
if err != nil {
return fail(err)
}
}
if err := expectDelim(dec, '}'); err != nil {
return fail(err)
}
// Список канонизируется: порядок ключей в JSON от HAE нестабилен, а
// значение уезжает в базу и сравнивается между доставками.
sort.Strings(uncovered)
return metrics, uncovered, dropped, nil
}
// decodeData разбирает объект data, собирая metrics и имена непокрытых секций.
func decodeData(dec *json.Decoder, seen map[string]struct{}) ([]metricEnvelope, []string, int, error) {
tok, err := dec.Token()
if err != nil {
return nil, nil, 0, err
}
// data не объект — прежнее поведение: ошибка ровно там, где была.
if d, ok := tok.(json.Delim); !ok || d != '{' {
return nil, nil, 0, fmt.Errorf("data: ожидался объект, встречено %v", tok)
}
var (
metrics []metricEnvelope
uncovered []string
dropped int
)
for dec.More() {
name, err := memberName(dec)
if err != nil {
return nil, nil, 0, err
}
if covered(name) {
// Повтор ключа metrics JSON допускает; секции ОБЪЕДИНЯЮТСЯ, а не
// побеждает последняя: терять точки молча нельзя.
var part []metricEnvelope
if err := dec.Decode(&part); err != nil {
return nil, nil, 0, err
}
metrics = append(metrics, part...)
continue
}
if err := swallow(dec); err != nil {
return nil, nil, 0, err
}
if _, dup := seen[name]; dup {
continue
}
seen[name] = struct{}{}
if len(uncovered) >= maxUncovered {
dropped++
continue
}
uncovered = append(uncovered, clipSection(name))
}
if _, err := dec.Token(); err != nil { // закрывающая скобка data
return nil, nil, 0, err
}
return metrics, uncovered, dropped, nil
}
// memberName читает имя члена объекта. Token() отдаёт имя уже после разбора
// escape-последовательностей, поэтому границы считаются по декодированному.
func memberName(dec *json.Decoder) (string, error) {
tok, err := dec.Token()
if err != nil {
return "", err
}
name, ok := tok.(string)
if !ok {
return "", fmt.Errorf("ожидалось имя члена, встречено %v", tok)
}
return name, nil
}
// swallow проглатывает значение целиком, ничего не удерживая.
func swallow(dec *json.Decoder) error {
var skip json.RawMessage
return dec.Decode(&skip)
}
func expectDelim(dec *json.Decoder, want json.Delim) error {
tok, err := dec.Token()
if err != nil {
return err
}
if d, ok := tok.(json.Delim); !ok || d != want {
return fmt.Errorf("ожидалось %q, встречено %v", want, tok)
}
return nil
}
// clipSection обрезает слишком длинное имя по границе рун и помечает обрезку.
// Маркер приписывается СВЕРХ предела: обрезка не инъективна, и обрезанное имя
// сравнению со словарём известных секций не подлежит.
func clipSection(name string) string {
if len(name) <= maxUncoveredLen {
return name
}
cut := maxUncoveredLen
for cut > 0 && !utf8.RuneStart(name[cut]) {
cut--
}
return name[:cut] + "…"
}
// decodeGroup разбирает точки одной метрики. Точка без разбираемой метки
+157
View File
@@ -3,11 +3,14 @@ package hae_test
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"unicode/utf8"
"git.vakhrushev.me/av/healthlog/internal/hae"
)
@@ -544,3 +547,157 @@ func FuzzParse(f *testing.F) {
}
})
}
// Половина живого потока состоит из непокрытых секций целиком (48 доставок из
// 99: workouts и stateOfMind). Без списка они неотличимы от разобранной
// доставки с пустой секцией метрик, и ретеншен, ориентируясь на статус, срезал
// бы тела, которые для stateOfMind единственный источник.
func TestParseПеречисляетНепокрытыеСекции(t *testing.T) {
t.Parallel()
res, err := hae.Parse(load(t, "uncovered_sections.json"), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
want := []string{"stateOfMind", "workouts"}
if !slices.Equal(res.Uncovered, want) {
t.Errorf("непокрытые %v, ожидались %v", res.Uncovered, want)
}
if len(res.Points) == 0 {
t.Error("точки метрик обязаны сохраниться: непокрытая секция рядом им не мешает")
}
if res.UncoveredDropped != 0 {
t.Errorf("отброшено %d имён, ожидалось 0", res.UncoveredDropped)
}
}
func TestParseНепокрытыеСекцииГраницыИДетерминизм(t *testing.T) {
t.Parallel()
t.Run("доставка из одной непокрытой секции", func(t *testing.T) {
t.Parallel()
res, err := hae.Parse([]byte(`{"data":{"stateOfMind":[{"x":1}]}}`), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if !slices.Equal(res.Uncovered, []string{"stateOfMind"}) {
t.Errorf("непокрытые %v", res.Uncovered)
}
if len(res.Points) != 0 {
t.Errorf("точек %d, ожидалось 0", len(res.Points))
}
})
t.Run("доставка из одних метрик списка не даёт", func(t *testing.T) {
t.Parallel()
res, err := hae.Parse([]byte(`{"data":{"metrics":[]}}`), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if len(res.Uncovered) != 0 {
t.Errorf("непокрытые %v, ожидался пустой список", res.Uncovered)
}
})
// Порядок ключей в JSON от HAE нестабилен, а список уезжает в базу и
// сравнивается между доставками: зависящий от порядка на проводе список
// сравнивать нельзя.
t.Run("порядок и повторы не влияют", func(t *testing.T) {
t.Parallel()
bodies := []string{
`{"data":{"workouts":[],"stateOfMind":[],"ecg":[]}}`,
`{"data":{"ecg":[],"workouts":[],"stateOfMind":[]}}`,
`{"data":{"stateOfMind":[],"ecg":[],"workouts":[],"ecg":[]}}`,
}
want := []string{"ecg", "stateOfMind", "workouts"}
for _, b := range bodies {
res, err := hae.Parse([]byte(b), hae.Meta{})
if err != nil {
t.Fatalf("разбор %s: %v", b, err)
}
if !slices.Equal(res.Uncovered, want) {
t.Errorf("тело %s дало %v, ожидалось %v", b, res.Uncovered, want)
}
}
})
// Повтор ключа metrics JSON допускает; секции объединяются, а не побеждает
// последняя — терять точки молча нельзя.
t.Run("повтор metrics объединяет секции", func(t *testing.T) {
t.Parallel()
const body = `{"data":{"metrics":[{"name":"a","data":[]}],"metrics":[{"name":"b","data":[]}]}}`
res, err := hae.Parse([]byte(body), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if res.Metrics != 2 {
t.Errorf("метрик %d, ожидалось 2 — секции обязаны объединиться", res.Metrics)
}
})
t.Run("границы списка видны", func(t *testing.T) {
t.Parallel()
var sb strings.Builder
sb.WriteString(`{"data":{`)
for i := range 40 {
if i > 0 {
sb.WriteByte(',')
}
fmt.Fprintf(&sb, `"s%02d":[]`, i)
}
sb.WriteString(`}}`)
res, err := hae.Parse([]byte(sb.String()), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if len(res.Uncovered) != 32 {
t.Errorf("имён %d, ожидалось 32", len(res.Uncovered))
}
if res.UncoveredDropped != 8 {
t.Errorf("отброшено %d, ожидалось 8 — иначе «ровно 32» неотличимо от «пришло пятьсот»", res.UncoveredDropped)
}
})
t.Run("длинное имя обрезано и помечено", func(t *testing.T) {
t.Parallel()
long := strings.Repeat("щ", 60) // 120 байт
res, err := hae.Parse([]byte(`{"data":{"`+long+`":[]}}`), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if len(res.Uncovered) != 1 {
t.Fatalf("непокрытые %v", res.Uncovered)
}
got := res.Uncovered[0]
if !strings.HasSuffix(got, "…") {
t.Errorf("имя %q без маркера обрезки: обрезка не инъективна и обязана быть видимой", got)
}
if !utf8.ValidString(got) {
t.Errorf("имя %q обрезано посреди руны", got)
}
})
// Ошибка после разобранной секции metrics: правило прежнее — при ошибке
// точек нет вовсе, иначе часть точек оказалась бы в витрине под статусом,
// по которому доставку никто не подберёт.
t.Run("обрыв после метрик точек не отдаёт", func(t *testing.T) {
t.Parallel()
const body = `{"data":{"metrics":[{"name":"a","data":[{"date":"2025-06-05 10:00:00 +0300","qty":1}]}],"work`
res, err := hae.Parse([]byte(body), hae.Meta{})
if !errors.Is(err, hae.ErrMalformed) {
t.Fatalf("ошибка %v, ожидалась ErrMalformed", err)
}
if len(res.Points) != 0 {
t.Errorf("точек %d, ожидалось 0", len(res.Points))
}
})
}
+76
View File
@@ -82,3 +82,79 @@ func largeBody(metrics, points int) []byte {
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())
}
+49
View File
@@ -0,0 +1,49 @@
{
"data": {
"metrics": [
{
"name": "step_count",
"units": "count",
"data": [
{"date": "2025-06-05 09:00:00 +0300", "qty": 41.0, "source": "Device A"},
{"date": "2025-06-05 09:01:00 +0300", "qty": 17.0, "source": "Device A"},
{"date": "2025-06-05 09:02:00 +0300", "qty": 82.0, "source": "Device A"},
{"date": "2025-06-05 09:03:00 +0300", "qty": 5.0, "source": "Device A"},
{"date": "2025-06-05 09:04:00 +0300", "qty": 63.0, "source": "Device A"},
{"date": "2025-06-05 09:05:00 +0300", "qty": 28.0, "source": "Device A"},
{"date": "2025-06-05 09:06:00 +0300", "qty": 94.0, "source": "Device A"},
{"date": "2025-06-05 09:07:00 +0300", "qty": 12.0, "source": "Device A"},
{"date": "2025-06-05 09:08:00 +0300", "qty": 71.0, "source": "Device A"},
{"date": "2025-06-05 09:09:00 +0300", "qty": 36.0, "source": "Device A"}
]
}
],
"workouts": [
{
"id": "00000000-0000-0000-0000-000000000001",
"name": "В помещении Ходьба",
"start": "2025-06-05 08:00:00 +0300",
"end": "2025-06-05 08:30:00 +0300",
"duration": 1800,
"heartRateData": [
{"date": "2025-06-05 08:00:07 +0300", "Min": 91.0, "Avg": 94.5, "Max": 98.0, "units": "count/min"},
{"date": "2025-06-05 08:00:21 +0300", "Min": 93.0, "Avg": 95.5, "Max": 99.0, "units": "count/min"}
],
"route": [
{"lat": 10.0, "lon": 20.0, "altitude": 30.0, "timestamp": "2025-06-05 08:00:07 +0300"}
]
}
],
"stateOfMind": [
{
"id": "00000000-0000-0000-0000-000000000002",
"start": "2025-06-05T05:12:33Z",
"end": "2025-06-05T05:12:33Z",
"kind": "momentary_emotion",
"valence": 0.25,
"labels": ["slightly_pleasant"],
"associations": []
}
]
}
}