- Правило покрытия получило второй разряд (условный, как у точек), запрет вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и ряд из null больше не затирают маршрут. Победитель внутри доставки стал функцией множества версий — общим помощником с точками, — а провенанс поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину к прежнему содержимому. - Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает на старте; текст ошибки разбора не несёт значений из тела. - Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты: безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя был квадратичен по числу присланных версий одного ключа.
817 lines
40 KiB
Go
817 lines
40 KiB
Go
// Package hae — разбор тела доставки Health Auto Export в точки.
|
||
//
|
||
// Отдельно от хранения потому, что у разбора будет второй потребитель
|
||
// (пересборка витрины из архива) и второй источник (родной экспорт Apple —
|
||
// свой формат поверх того же хранилища). Пакет ничего не знает ни про SQLite,
|
||
// ни про архив, и ничего не пишет: `Parse` — чистая функция от тела и
|
||
// заголовков.
|
||
//
|
||
// Правила разбора выведены измерением живого потока, а не спроектированы:
|
||
// docs/local-research.md, находки 2, 30, 33, 35, 36, 38, 39, 47. Документация
|
||
// HAE местами расходится с тем, что приложение шлёт на самом деле, поэтому
|
||
// источник истины по формату — пакеты в testdata.
|
||
package hae
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"sort"
|
||
"time"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
// Layer — подробность, в которой метрика приехала. Выводится из выравнивания
|
||
// меток времени, а не из заголовка доставки: заголовок `Default` наблюдался
|
||
// одновременно у посекундного, минутного и часового режимов (находка 33).
|
||
type Layer string
|
||
|
||
// Слои хранения. `sample` появится с импортом родного экспорта Apple, `day`
|
||
// назначается схемам с фиксированной гранулярностью и не выводится.
|
||
const (
|
||
LayerSample Layer = "sample"
|
||
LayerRaw Layer = "raw"
|
||
LayerMinute Layer = "minute"
|
||
LayerHour Layer = "hour"
|
||
LayerDay Layer = "day"
|
||
)
|
||
|
||
// Ошибки разбора.
|
||
var (
|
||
// ErrMalformed — тело не разбирается как JSON ожидаемой формы.
|
||
ErrMalformed = errors.New("тело не разбирается")
|
||
|
||
// ErrLayerUnknown — в доставке есть метрики, но определить их слой нечем:
|
||
// плотных метрик нет, у автоматизации нет предыдущего надёжного слоя, а
|
||
// заголовок ненадёжен. Точки не сохраняются, тело остаётся в архиве —
|
||
// доставку подберёт пересборка, когда слой станет известен.
|
||
//
|
||
// Молчаливый выбор `raw` здесь недопустим: призрачный разрез поедет в
|
||
// каталог и в правило Read API «самый мелкий слой, покрывающий диапазон».
|
||
ErrLayerUnknown = errors.New("слой доставки не определяется")
|
||
)
|
||
|
||
// Порог плотности: метрика, у которой не меньше стольких точек с метками,
|
||
// классифицируется по собственному выравниванию. Редкая наследует слой
|
||
// доставки — её собственное выравнивание ничего не значит, потому что одна
|
||
// метка на часе бывает и у минутного ряда.
|
||
const denseThreshold = 10
|
||
|
||
// timeLayout — формат метки точки в секции metrics. Другого там не
|
||
// встречается: RFC 3339 живёт только в stateOfMind, а Unix-эпоха — внутри
|
||
// heartbeatSeries, и меткой точки не является.
|
||
const timeLayout = "2006-01-02 15:04:05 -0700"
|
||
|
||
// Point — одна разобранная точка.
|
||
type Point struct {
|
||
// Metric — имя метрики как прислал HAE. Исключение — sleep_analysis: под
|
||
// одним именем приезжают две несовместимые схемы, и они разводятся.
|
||
Metric string
|
||
Units string
|
||
Layer Layer
|
||
|
||
// Start и End — координаты точки в UTC. У точки-измерения End равен Start:
|
||
// ключ одной формы для всех точек, потому что интервальная и точечная
|
||
// формы не встречаются вперемешку внутри метрики одной доставки
|
||
// (находка 47).
|
||
Start time.Time
|
||
End time.Time
|
||
|
||
// OffsetSeconds — смещение исходной зоны. Нормализовать время без него
|
||
// значит потерять, в каком часовом поясе человек находился.
|
||
OffsetSeconds int
|
||
|
||
// Raw — содержимое точки исходными байтами, как пришло в теле. Пересборка
|
||
// повторной сериализацией теряет литерал (`1.0` → `1`, целые больше 2^53
|
||
// сдвигаются, невалидный UTF-8 → U+FFFD), и потеря не видна тестам на
|
||
// фикстурах: они сравнивают разобранное с разобранным.
|
||
Raw json.RawMessage
|
||
|
||
// local — метка в исходной зоне. Наружу не отдаётся: хранится всегда UTC
|
||
// плюс офсет. Нужна выводу слоя — HAE строит сетку по МЕСТНОМУ времени, и
|
||
// выравнивание, посчитанное по UTC, объявляет часовую выгрузку минутной в
|
||
// зонах с получасовым смещением (+0530, +0545, +0930).
|
||
local time.Time
|
||
}
|
||
|
||
// Entity — сущность с собственным идентификатором: тренировка или запись
|
||
// секции вроде stateOfMind. От точки отличается тем, что её адресует сам `id`,
|
||
// а не координаты, и слоя у неё нет вовсе: подробности выгрузки у этих секций
|
||
// в интерфейсе HAE не бывает.
|
||
type Entity struct {
|
||
// ID — идентификатор из HealthKit. Приходит из тела и ограничен по длине:
|
||
// уезжает и в первичный ключ, и в записи лога.
|
||
ID string
|
||
// Kind — верхнеуровневый ключ секции HAE ДОСЛОВНО (`stateOfMind`, не
|
||
// `state_of_mind`): инвариант «форма Apple не транслируется» относится и к
|
||
// именам секций, а переименование после того, как значение легло в базу,
|
||
// стоило бы миграции данных. У тренировки род один и в ключ не входит.
|
||
Kind string
|
||
// Name — имя тренировки как прислал HAE, локализованное («В помещении
|
||
// Ходьба»). У записей пустое.
|
||
Name string
|
||
|
||
// Start и End — координаты в UTC. Конец, которого нет или который не
|
||
// читается, равен началу: ключ сущности — `id`, схлопывать нечего, а истина
|
||
// остаётся в Raw. У точки то же вырождение запрещено — там оно схлопнуло бы
|
||
// две записи в одну координату.
|
||
Start time.Time
|
||
End time.Time
|
||
|
||
// OffsetSeconds — смещение зоны НАЧАЛА. Колонка одна, а тренировка через
|
||
// смену зоны дала бы два разных.
|
||
OffsetSeconds int
|
||
|
||
// Duration — длительность тренировки в секундах, как прислал HAE. Не
|
||
// вычисляется из интервала: HAE шлёт 91.746 при интервале в 91 секунду.
|
||
// Отсутствие выражается nil, а не нулём: ноль — законная длительность.
|
||
Duration *float64
|
||
|
||
// Raw — содержимое сущности исходными байтами, как пришло в теле, включая
|
||
// маршрут и внутренние ряды.
|
||
Raw json.RawMessage
|
||
}
|
||
|
||
// Result — итог разбора доставки. Частичные исходы живут в счётчиках, а не в
|
||
// ошибке: пакет, у которого не разобралась одна точка из тысячи, — обычное
|
||
// дело, и терять из-за неё остальное нельзя.
|
||
type Result struct {
|
||
Points []Point
|
||
|
||
// Workouts и Records — сущности с собственным идентификатором. Разведены,
|
||
// потому что у тренировки есть заголовок (имя, интервал, длительность), по
|
||
// которому идёт выборка, а у записи его нет.
|
||
Workouts []Entity
|
||
Records []Entity
|
||
|
||
// SkippedNoID — сущности без пригодного идентификатора: пустого, нет вовсе
|
||
// или длиннее предела. Один счётчик на все три случая: исход у них общий, а
|
||
// различает их только тело, лежащее в архиве.
|
||
SkippedNoID int
|
||
// SkippedEntityNoTime — сущности с идентификатором, но без разбираемой
|
||
// метки времени.
|
||
SkippedEntityNoTime int
|
||
// SkippedEntityMalformed — элементы секции, не разобравшиеся как объект.
|
||
SkippedEntityMalformed int
|
||
|
||
// Uncovered — верхнеуровневые ключи `data`, которых разбор не покрывает,
|
||
// отсортированные и без повторов. Половина живого потока состоит из таких
|
||
// доставок целиком (48 из 99: workouts и stateOfMind), и без этого списка
|
||
// они неотличимы от разобранной доставки с пустой секцией метрик.
|
||
//
|
||
// Список канонизирован потому, что уезжает в базу и сравнивается между
|
||
// доставками, а порядок ключей в JSON от HAE нестабилен.
|
||
Uncovered []string
|
||
// UncoveredDropped — сколько имён отброшено границей списка. Молчаливое
|
||
// усечение сделало бы список уверенным, но неполным ответом на вопрос «что
|
||
// останется потерянным, если тело удалить».
|
||
UncoveredDropped int
|
||
|
||
// Metrics — сколько метрик встретилось в секции.
|
||
Metrics int
|
||
// SkippedNoTime — точки без разбираемой метки времени.
|
||
SkippedNoTime int
|
||
// SkippedMalformed — точки, не разобравшиеся как объект JSON.
|
||
SkippedMalformed int
|
||
// SkippedBadEnd — точки, у которых есть `end`, но он не разбирается.
|
||
// Вырождать такую точку в мгновенную нельзя: она схлопнулась бы с соседней
|
||
// по координате.
|
||
SkippedBadEnd int
|
||
|
||
// Layer — слой, выведенный для доставки в целом (тот, что наследуют редкие
|
||
// метрики). Пустой, если плотных метрик не было и наследовать было нечего.
|
||
// Его сохраняет вызывающий, чтобы следующая доставка той же автоматизации
|
||
// могла его унаследовать.
|
||
Layer Layer
|
||
// LayerMismatch — выведенный слой разошёлся с НАДЁЖНЫМ заголовком.
|
||
// Заголовок `Default` в сравнении не участвует: он не означает режима, и
|
||
// сравнение с ним давало бы WARN на каждой доставке потока в пять минут.
|
||
LayerMismatch bool
|
||
// HeaderLayer — слой по заголовку, если заголовок надёжен.
|
||
HeaderLayer Layer
|
||
}
|
||
|
||
// Meta — что доставка рассказала о себе заголовками, плюс память о прошлых
|
||
// доставках той же автоматизации.
|
||
type Meta struct {
|
||
// Aggregation — заголовок `automation-aggregation`. Надёжен только в
|
||
// значениях `Minutes` и `Hours`.
|
||
Aggregation string
|
||
|
||
// FallbackLayer — последний надёжно выведенный слой этой же автоматизации
|
||
// (`automation-id`). Нужен доставкам без плотных метрик: измерено 2 такие
|
||
// из 89, обе с заголовком `Default`. Ищет и передаёт его вызывающий —
|
||
// разбор остаётся чистой функцией.
|
||
FallbackLayer Layer
|
||
}
|
||
|
||
// Parse разбирает секцию metrics тела доставки в точки.
|
||
//
|
||
// Ошибка возвращается только когда точек не будет вовсе: тело не JSON
|
||
// (ErrMalformed) или слой не определяется (ErrLayerUnknown). Всё остальное —
|
||
// счётчики в Result. Отсутствие секции metrics ошибкой не является: доставки
|
||
// с одними тренировками или состоянием разума — норма.
|
||
func Parse(body []byte, meta Meta) (res Result, err error) {
|
||
// Разбор чужого формата обязан отвечать ошибкой, а не паникой: приём не
|
||
// имеет права упасть из-за того, что HAE прислал невиданное. Перехват
|
||
// стоит здесь, внутри разбора, а не выше: паника из хранилища — настоящий
|
||
// дефект, и глушить её нельзя.
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
res = Result{}
|
||
err = fmt.Errorf("%w: паника разбора: %s", ErrMalformed, clip(fmt.Sprint(r)))
|
||
}
|
||
}()
|
||
|
||
env, err := decodeEnvelope(body)
|
||
if err != nil {
|
||
return Result{}, err
|
||
}
|
||
res.Uncovered = env.uncovered
|
||
res.UncoveredDropped = env.dropped
|
||
|
||
res.Workouts = decodeEntities(env.workouts, workoutsSection, &res)
|
||
res.Records = decodeEntities(env.stateOfMind, stateOfMindSection, &res)
|
||
|
||
metrics := env.metrics
|
||
res.Metrics = len(metrics)
|
||
if len(metrics) == 0 {
|
||
return res, nil
|
||
}
|
||
|
||
groups := make([]group, 0, len(metrics))
|
||
for _, m := range metrics {
|
||
g := decodeGroup(m, &res)
|
||
if len(g.summaries) > 0 {
|
||
groups = append(groups, group{
|
||
metric: sleepSummaryMetric,
|
||
units: g.units,
|
||
points: g.summaries,
|
||
layer: LayerDay,
|
||
fixed: true,
|
||
})
|
||
g.summaries = nil
|
||
}
|
||
if len(g.points) > 0 {
|
||
groups = append(groups, g)
|
||
}
|
||
}
|
||
if len(groups) == 0 {
|
||
return res, nil
|
||
}
|
||
|
||
res.HeaderLayer = headerLayer(meta.Aggregation)
|
||
if err := assignLayers(groups, meta, &res); err != nil {
|
||
// Список непокрытых секций переживает отказ: доставка, у которой не
|
||
// определился слой, обязана остаться записью о том, что в теле есть
|
||
// невосстановимая секция. Иначе ретеншен увидит failed без списка и
|
||
// решит, что терять нечего.
|
||
//
|
||
// Сущности при этом НЕ отдаются, хотя слоя у них нет и разобрались они
|
||
// успешно. «Всё или ничего» относится к доставке, а не к точкам: отдай
|
||
// мы их, доставка получила бы `failed` при частично записанной витрине,
|
||
// и повторная свёртка перестала бы быть no-op. Цена названа в спеке —
|
||
// такая доставка доедет пересборкой, а тело ждёт в архиве.
|
||
return Result{
|
||
Metrics: res.Metrics,
|
||
Uncovered: res.Uncovered,
|
||
UncoveredDropped: res.UncoveredDropped,
|
||
}, err
|
||
}
|
||
|
||
total := 0
|
||
for _, g := range groups {
|
||
total += len(g.points)
|
||
}
|
||
res.Points = make([]Point, 0, total)
|
||
for _, g := range groups {
|
||
for _, p := range g.points {
|
||
p.Layer = g.layer
|
||
res.Points = append(res.Points, p)
|
||
}
|
||
}
|
||
return res, nil
|
||
}
|
||
|
||
// group — точки одной метрики одной доставки: единица, для которой выводится
|
||
// слой. Классификация именно по метрике, а не по доставке: при перенастройке
|
||
// автоматизации приезжают смешанные доставки, и отнесение такой доставки к
|
||
// одному слою складывает минутные точки с посекундными (находка 33).
|
||
type group struct {
|
||
metric string
|
||
units string
|
||
|
||
points []Point
|
||
// layer — итоговый слой группы.
|
||
layer Layer
|
||
// fixed — слой назначен схемой, а не выведен (суточная сводка сна). Такие
|
||
// группы не участвуют в определении слоя доставки: сводок бывает больше
|
||
// порога плотности, и их полуночные метки назначили бы всей доставке hour.
|
||
fixed bool
|
||
// alignment — самое мелкое выравнивание среди меток группы.
|
||
alignment Layer
|
||
dense bool
|
||
|
||
// summaries — точки, схема которых опознана прямо при разборе и слой
|
||
// которым назначен, а не выведен (суточная сводка сна). Держатся отдельно,
|
||
// потому что в определении слоя доставки не участвуют: сводок бывает больше
|
||
// порога плотности, и их полуночные метки назначили бы всей доставке `hour`.
|
||
summaries []Point
|
||
}
|
||
|
||
// envelope — форма тела, ровно настолько подробная, насколько нужно разбору.
|
||
//
|
||
// Точки держатся сырыми сообщениями и декодируются по одной: разбор тела в
|
||
// 42 МиБ через map[string]any удерживает 197 МиБ кучи против 54 МиБ у этой
|
||
// формы. Вместе с самим телом пик доходил бы до ~300 МиБ на доставку — это
|
||
// OOM ровно на пике потока, когда терять доставки дороже всего.
|
||
// Секции, которые разбор покрывает. Прочие секции с собственными `id` (`ecg`,
|
||
// `symptoms`, `cycleTracking`, `medications`, `heartRateNotifications`)
|
||
// покрытыми намеренно не становятся: живой поток не приносил их ни разу, их
|
||
// форма никем не наблюдалась, а полнота покрытия HealthKit ради полноты целью
|
||
// проекта не является.
|
||
const (
|
||
metricsSection = "metrics"
|
||
workoutsSection = "workouts"
|
||
stateOfMindSection = "stateOfMind"
|
||
)
|
||
|
||
// decodeCovered разбирает секцию, если разбор её покрывает; второй возврат
|
||
// говорит, взялся ли он за неё.
|
||
//
|
||
// Один источник и для разбора, и для перечисления непокрытых: перечисляющий
|
||
// спрашивает ровно того, кто разбирает, поэтому состояние «секция разбирается,
|
||
// но числится непокрытой» невыразимо по построению. Отдельный предикат
|
||
// `covered` разошёлся бы с этим switch при первой же новой секции.
|
||
//
|
||
// Повтор ключа покрытой секции JSON допускает; секции ОБЪЕДИНЯЮТСЯ, а не
|
||
// побеждает последняя: терять данные молча нельзя.
|
||
func decodeCovered(name string, dec *json.Decoder, env *envelope) (bool, error) {
|
||
switch name {
|
||
case metricsSection:
|
||
var part []metricEnvelope
|
||
if err := dec.Decode(&part); err != nil {
|
||
return true, err
|
||
}
|
||
env.metrics = append(env.metrics, part...)
|
||
return true, nil
|
||
case workoutsSection:
|
||
part, err := decodeSection(dec)
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
env.workouts = append(env.workouts, part...)
|
||
return true, nil
|
||
case stateOfMindSection:
|
||
part, err := decodeSection(dec)
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
env.stateOfMind = append(env.stateOfMind, part...)
|
||
return true, nil
|
||
default:
|
||
return false, nil
|
||
}
|
||
}
|
||
|
||
// Границы на список непокрытых ключей. Тело контролирует отправитель целиком:
|
||
// без границ сто тысяч однобуквенных ключей превращаются в одну строку в базе
|
||
// и одну строку в логе того же порядка. Секций у HAE восемь, самое длинное имя
|
||
// — heartRateNotifications (22 байта), так что запас велик.
|
||
const (
|
||
maxUncovered = 32
|
||
maxUncoveredLen = 64
|
||
)
|
||
|
||
type metricEnvelope struct {
|
||
Name string `json:"name"`
|
||
Units string `json:"units"`
|
||
Data []json.RawMessage `json:"data"`
|
||
}
|
||
|
||
// pointHead — поля точки, нужные разбору. Всё остальное остаётся в Raw и
|
||
// хранится дословно: «служебных» полей у точки нет, отбрасывать нечего.
|
||
type pointHead struct {
|
||
Date string `json:"date"`
|
||
Start string `json:"start"`
|
||
End string `json:"end"`
|
||
|
||
// TotalSleep различает две схемы под именем sleep_analysis: поэпизодную и
|
||
// суточную сводку. Общих полей, кроме date и source, у них нет.
|
||
TotalSleep *json.RawMessage `json:"totalSleep"`
|
||
}
|
||
|
||
// 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) (envelope, error) {
|
||
var env envelope
|
||
|
||
fail := func(e error) (envelope, error) {
|
||
return envelope{}, fmt.Errorf("%w: %s", ErrMalformed, ClipCause(e))
|
||
}
|
||
|
||
dec := json.NewDecoder(bytes.NewReader(body))
|
||
|
||
// Верхний уровень тела: интересует только data. Прочие ключи конверта в
|
||
// список не идут — иначе в одном списке смешались бы имена секций и мусор
|
||
// конверта, а форму `{"data": …}` проверяет приём.
|
||
at := dec.InputOffset()
|
||
tok, err := dec.Token()
|
||
if err != nil {
|
||
return fail(err)
|
||
}
|
||
// Голый null телом ошибкой не был и не становится: прежний разбор
|
||
// раскладывал его в пустую структуру. Границы поведения этой задачей не
|
||
// двигаются — она добавляет список, а не строгость.
|
||
if tok == nil {
|
||
return envelope{}, nil
|
||
}
|
||
if d, ok := tok.(json.Delim); !ok || d != '{' {
|
||
return fail(fmt.Errorf("ожидался объект, встречено %s", tokenDesc(tok, at)))
|
||
}
|
||
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
|
||
}
|
||
// Повтор самого члена `data` JSON допускает, и результаты
|
||
// НАКАПЛИВАЮТСЯ, а не замещаются: присваивание теряло бы секции первого
|
||
// члена целиком, причём молча — их имена уже отмечены в `seen` и во
|
||
// второй список непокрытых не попали бы. Правило то же, что уровнем
|
||
// ниже для повтора ключа секции.
|
||
if err := decodeData(dec, seen, &env); err != nil {
|
||
return fail(err)
|
||
}
|
||
}
|
||
if err := expectDelim(dec, '}'); err != nil {
|
||
return fail(err)
|
||
}
|
||
|
||
// Список канонизируется: порядок ключей в JSON от HAE нестабилен, а
|
||
// значение уезжает в базу и сравнивается между доставками.
|
||
sort.Strings(env.uncovered)
|
||
return env, nil
|
||
}
|
||
|
||
// envelope — что разбор вынул из тела: покрытые секции и имена непокрытых.
|
||
type envelope struct {
|
||
metrics []metricEnvelope
|
||
workouts []json.RawMessage
|
||
stateOfMind []json.RawMessage
|
||
uncovered []string
|
||
dropped int
|
||
}
|
||
|
||
// decodeData разбирает объект data, дописывая в конверт покрытые секции и
|
||
// имена непокрытых.
|
||
func decodeData(dec *json.Decoder, seen map[string]struct{}, env *envelope) error {
|
||
at := dec.InputOffset()
|
||
tok, err := dec.Token()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// data не объект — прежнее поведение: ошибка ровно там, где была.
|
||
if d, ok := tok.(json.Delim); !ok || d != '{' {
|
||
return fmt.Errorf("data: ожидался объект, встречено %s", tokenDesc(tok, at))
|
||
}
|
||
|
||
for dec.More() {
|
||
name, err := memberName(dec)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
handled, err := decodeCovered(name, dec, env)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if handled {
|
||
continue
|
||
}
|
||
|
||
if err := swallow(dec); err != nil {
|
||
return err
|
||
}
|
||
if _, dup := seen[name]; dup {
|
||
continue
|
||
}
|
||
seen[name] = struct{}{}
|
||
if len(env.uncovered) >= maxUncovered {
|
||
env.dropped++
|
||
continue
|
||
}
|
||
env.uncovered = append(env.uncovered, clipSection(name))
|
||
}
|
||
if _, err := dec.Token(); err != nil { // закрывающая скобка data
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// decodeSection читает секцию сущностей элементами исходных байтов.
|
||
//
|
||
// Разбор до `json.RawMessage`, а не до структуры: сущность хранится дословно, и
|
||
// декодирование в типизированное значение потеряло бы литерал — ровно то, от
|
||
// чего защищает `Point.Raw`.
|
||
func decodeSection(dec *json.Decoder) ([]json.RawMessage, error) {
|
||
var part []json.RawMessage
|
||
if err := dec.Decode(&part); err != nil {
|
||
return nil, err
|
||
}
|
||
return part, nil
|
||
}
|
||
|
||
// memberName читает имя члена объекта. Token() отдаёт имя уже после разбора
|
||
// escape-последовательностей, поэтому границы считаются по декодированному.
|
||
func memberName(dec *json.Decoder) (string, error) {
|
||
at := dec.InputOffset()
|
||
tok, err := dec.Token()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
name, ok := tok.(string)
|
||
if !ok {
|
||
return "", fmt.Errorf("ожидалось имя члена, встречено %s", tokenDesc(tok, at))
|
||
}
|
||
return name, nil
|
||
}
|
||
|
||
// maxCauseLen — предел длины чужой причины в тексте нашей ошибки.
|
||
//
|
||
// Сообщения самого разбора значений не несут (см. tokenDesc), но ошибка может
|
||
// прийти и из encoding/json, а его UnmarshalTypeError кладёт в текст ЛИТЕРАЛ
|
||
// значения: тело из миллиона цифр давало текст ошибки в мегабайт, и он уезжал
|
||
// атрибутом `error` выше DEBUG. Предел держится здесь, на границе, а не у
|
||
// логирующего: обрезка живёт в другом месте и о новой ошибке разбора не узнает.
|
||
const maxCauseLen = 200
|
||
|
||
// ClipCause переводит чужую ошибку в ограниченную по длине строку.
|
||
//
|
||
// Экспортировано ради приёма: он проверяет форму конверта тем же
|
||
// encoding/json и обязан держать тот же предел — иначе инвариант обходится
|
||
// через соседний пакет.
|
||
func ClipCause(err error) string {
|
||
if err == nil {
|
||
return ""
|
||
}
|
||
return clip(err.Error())
|
||
}
|
||
|
||
func clip(s string) string {
|
||
if len(s) <= maxCauseLen {
|
||
return s
|
||
}
|
||
// По границе рун: обрезка посреди многобайтовой руны даёт мусор в логе.
|
||
cut := maxCauseLen
|
||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||
cut--
|
||
}
|
||
return s[:cut] + "…"
|
||
}
|
||
|
||
// tokenDesc описывает встреченный токен БЕЗ его значения: род и смещение начала
|
||
// во входе.
|
||
//
|
||
// Значение из тела в сообщение не попадает никогда. Инвариант «тела запросов
|
||
// только на DEBUG и с обрезкой» обходится одним `%v`: строка в 8 МиБ на месте
|
||
// ожидаемого объекта давала текст ошибки в 8 МиБ, и он уезжал атрибутом `error`
|
||
// на уровень WARN — то есть содержимое доставки оказывалось в логе целиком.
|
||
// Предел держит само сообщение, а не обрезка на стороне логирующего: обрезка
|
||
// живёт в другом месте и о новой ошибке разбора не узнает.
|
||
//
|
||
// Род называется словарём JSON, а не именем типа языка: `json.Delim` не говорит
|
||
// ничего о том, какая скобка встретилась. Сам делимитер печатается значением —
|
||
// он из фиксированного набора и содержимого не раскрывает.
|
||
//
|
||
// Смещение берётся ДО чтения токена: InputOffset() отдаёт позицию конца
|
||
// последнего возвращённого токена, и снятое после оно указывало бы на конец
|
||
// виновного значения — то есть на восемь мегабайт дальше начала проблемы.
|
||
func tokenDesc(tok json.Token, at int64) string {
|
||
kind := "?"
|
||
switch v := tok.(type) {
|
||
case json.Delim:
|
||
kind = fmt.Sprintf("%q", string(v))
|
||
case string:
|
||
kind = "string"
|
||
case json.Number, float64:
|
||
kind = "number"
|
||
case bool:
|
||
kind = "bool"
|
||
case nil:
|
||
kind = "null"
|
||
}
|
||
return fmt.Sprintf("%s на смещении %d", kind, at)
|
||
}
|
||
|
||
// swallow проглатывает значение целиком, ничего не удерживая.
|
||
func swallow(dec *json.Decoder) error {
|
||
var skip json.RawMessage
|
||
return dec.Decode(&skip)
|
||
}
|
||
|
||
func expectDelim(dec *json.Decoder, want json.Delim) error {
|
||
at := dec.InputOffset()
|
||
tok, err := dec.Token()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if d, ok := tok.(json.Delim); !ok || d != want {
|
||
return fmt.Errorf("ожидалось %q, встречено %s", want, tokenDesc(tok, at))
|
||
}
|
||
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 разбирает точки одной метрики. Точка без разбираемой метки
|
||
// пропускается со счётчиком — ронять из-за неё остальную доставку незачем.
|
||
//
|
||
// Схема точки определяется ЗДЕСЬ ЖЕ, в том же проходе, где точка разобрана.
|
||
// Второй проход по исходному массиву был бы неверен: список разобранных точек
|
||
// уже отфильтрован пропусками, и любой пропуск сдвигал бы соответствие — эпизод
|
||
// сна уезжал бы под имя суточной сводки, а сводка под имя эпизода. Индексной
|
||
// корреляции между двумя списками здесь не существует по построению.
|
||
func decodeGroup(m metricEnvelope, res *Result) group {
|
||
g := group{metric: m.Name, units: m.Units, points: make([]Point, 0, len(m.Data))}
|
||
|
||
for _, raw := range m.Data {
|
||
var head pointHead
|
||
if err := json.Unmarshal(raw, &head); err != nil {
|
||
res.SkippedMalformed++
|
||
continue
|
||
}
|
||
|
||
start, ok := parseTime(head.Date, head.Start)
|
||
if !ok {
|
||
res.SkippedNoTime++
|
||
continue
|
||
}
|
||
|
||
end := start
|
||
if head.End != "" {
|
||
e, err := time.Parse(timeLayout, head.End)
|
||
if err != nil {
|
||
// Интервал, конец которого не читается, вырождать в точку
|
||
// нельзя: две записи с общим началом получили бы одну
|
||
// координату и одна из них исчезла бы. Пропускаем со
|
||
// счётчиком — тело остаётся в архиве.
|
||
res.SkippedBadEnd++
|
||
continue
|
||
}
|
||
end = e
|
||
}
|
||
|
||
_, offset := start.Zone()
|
||
p := Point{
|
||
Metric: m.Name,
|
||
Units: m.Units,
|
||
Start: start.UTC(),
|
||
End: end.UTC(),
|
||
OffsetSeconds: offset,
|
||
Raw: raw,
|
||
local: start,
|
||
}
|
||
|
||
// Под именем sleep_analysis HAE шлёт две несовместимые схемы:
|
||
// поэпизодную (start/end/value/qty) и суточную сводку
|
||
// (totalSleep/core/rem/deep/awake с меткой на местной полуночи). Имя
|
||
// sleep_analysis_summary — наше; инвариант «форма Apple не
|
||
// транслируется» это не нарушает: поля внутри точки не
|
||
// переименовываются, разделяются только имена метрик, под которыми
|
||
// HAE смешал две схемы.
|
||
if m.Name == sleepMetric && head.TotalSleep != nil {
|
||
p.Metric = sleepSummaryMetric
|
||
g.summaries = append(g.summaries, p)
|
||
continue
|
||
}
|
||
g.points = append(g.points, p)
|
||
}
|
||
|
||
g.dense = len(g.points) >= denseThreshold
|
||
g.alignment = finestAlignment(g.points)
|
||
return g
|
||
}
|
||
|
||
// Имена метрик сна: пришедшее от HAE и наше для суточной сводки.
|
||
const (
|
||
sleepMetric = "sleep_analysis"
|
||
sleepSummaryMetric = "sleep_analysis_summary"
|
||
)
|
||
|
||
// parseTime разбирает метку точки. Начало берётся из start, а при его
|
||
// отсутствии — из date. Измерено: start, когда он есть, всегда совпадает с
|
||
// date, поэтому правило не вводит второго источника метки — оно закрывает
|
||
// случай, когда HAE перестанет их дублировать.
|
||
func parseTime(date, start string) (time.Time, bool) {
|
||
s := start
|
||
if s == "" {
|
||
s = date
|
||
}
|
||
if s == "" {
|
||
return time.Time{}, false
|
||
}
|
||
|
||
t, err := time.Parse(timeLayout, s)
|
||
if err != nil {
|
||
return time.Time{}, false
|
||
}
|
||
return t, true
|
||
}
|
||
|
||
// finestAlignment возвращает самое мелкое выравнивание среди меток.
|
||
//
|
||
// Именно самое мелкое, а не преобладающее: у плотных метрик выравнивания
|
||
// перемешаны (active_energy — 1320 минутных меток и 21 часовая), потому что
|
||
// метка ровно на часе одновременно является и минутной. Метрика, у которой
|
||
// хоть одна метка стоит на середине часа, часовой не является.
|
||
func finestAlignment(points []Point) Layer {
|
||
finest := LayerHour
|
||
for _, p := range points {
|
||
// По МЕСТНОЙ метке, а не по UTC: HAE строит сетку в зоне телефона.
|
||
// В зонах с получасовым смещением (+0530 Индия, +0545 Непал, +0930
|
||
// Аделаида) ровный местный час превращается в UTC-метку на половине,
|
||
// и вся часовая выгрузка уехала бы в слой `minute` — а там столкнулась
|
||
// бы с настоящей минутной автоматизацией на координате hh:30 и завысила
|
||
// сумму минутного слоя вдвое.
|
||
switch {
|
||
case p.local.Second() != 0 || p.local.Nanosecond() != 0:
|
||
return LayerRaw
|
||
case p.local.Minute() != 0:
|
||
finest = LayerMinute
|
||
}
|
||
}
|
||
return finest
|
||
}
|
||
|
||
// headerLayer переводит заголовок в слой, но только когда заголовок надёжен.
|
||
// `Default` соответствует трём разным режимам выгрузки и не означает ничего.
|
||
func headerLayer(aggregation string) Layer {
|
||
switch aggregation {
|
||
case "Minutes":
|
||
return LayerMinute
|
||
case "Hours":
|
||
return LayerHour
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
// finer возвращает более мелкий из двух слоёв.
|
||
func finer(a, b Layer) Layer {
|
||
if rank(a) < rank(b) {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
func rank(l Layer) int {
|
||
switch l {
|
||
case LayerSample:
|
||
return 0
|
||
case LayerRaw:
|
||
return 1
|
||
case LayerMinute:
|
||
return 2
|
||
case LayerHour:
|
||
return 3
|
||
case LayerDay:
|
||
return 4
|
||
default:
|
||
return 5
|
||
}
|
||
}
|