feat: разбор и хранение тренировок и состояния разума
- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`; миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки с этими ключами - сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА — «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина расходилась бы с пересборкой молча - отпечаток витрины покрывает тренировки и записи и снимается одним снимком базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
This commit is contained in:
+165
-43
@@ -95,12 +95,66 @@ type Point struct {
|
||||
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), и без этого списка
|
||||
@@ -170,13 +224,17 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
|
||||
}
|
||||
}()
|
||||
|
||||
metrics, uncovered, dropped, err := decodeEnvelope(body)
|
||||
env, err := decodeEnvelope(body)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
res.Uncovered = uncovered
|
||||
res.UncoveredDropped = dropped
|
||||
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
|
||||
@@ -209,6 +267,12 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
|
||||
// определился слой, обязана остаться записью о том, что в теле есть
|
||||
// невосстановимая секция. Иначе ретеншен увидит failed без списка и
|
||||
// решит, что терять нечего.
|
||||
//
|
||||
// Сущности при этом НЕ отдаются, хотя слоя у них нет и разобрались они
|
||||
// успешно. «Всё или ничего» относится к доставке, а не к точкам: отдай
|
||||
// мы их, доставка получила бы `failed` при частично записанной витрине,
|
||||
// и повторная свёртка перестала бы быть no-op. Цена названа в спеке —
|
||||
// такая доставка доедет пересборкой, а тело ждёт в архиве.
|
||||
return Result{
|
||||
Metrics: res.Metrics,
|
||||
Uncovered: res.Uncovered,
|
||||
@@ -262,16 +326,53 @@ type group struct {
|
||||
// 42 МиБ через map[string]any удерживает 197 МиБ кучи против 54 МиБ у этой
|
||||
// формы. Вместе с самим телом пик доходил бы до ~300 МиБ на доставку — это
|
||||
// OOM ровно на пике потока, когда терять доставки дороже всего.
|
||||
// metricsSection — единственная секция, которую разбор покрывает сегодня.
|
||||
const metricsSection = "metrics"
|
||||
// Секции, которые разбор покрывает. Прочие секции с собственными `id` (`ecg`,
|
||||
// `symptoms`, `cycleTracking`, `medications`, `heartRateNotifications`)
|
||||
// покрытыми намеренно не становятся: живой поток не приносил их ни разу, их
|
||||
// форма никем не наблюдалась, а полнота покрытия HealthKit ради полноты целью
|
||||
// проекта не является.
|
||||
const (
|
||||
metricsSection = "metrics"
|
||||
workoutsSection = "workouts"
|
||||
stateOfMindSection = "stateOfMind"
|
||||
)
|
||||
|
||||
// covered говорит, покрывает ли разбор секцию с таким именем.
|
||||
// decodeCovered разбирает секцию, если разбор её покрывает; второй возврат
|
||||
// говорит, взялся ли он за неё.
|
||||
//
|
||||
// Функция, а не изменяемая карта: разбор и перечисление непокрытых ходят по
|
||||
// одному источнику, поэтому состояние «секция разбирается, но числится
|
||||
// непокрытой» невыразимо.
|
||||
func covered(section string) bool {
|
||||
return section == metricsSection
|
||||
// Один источник и для разбора, и для перечисления непокрытых: перечисляющий
|
||||
// спрашивает ровно того, кто разбирает, поэтому состояние «секция разбирается,
|
||||
// но числится непокрытой» невыразимо по построению. Отдельный предикат
|
||||
// `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
|
||||
}
|
||||
}
|
||||
|
||||
// Границы на список непокрытых ключей. Тело контролирует отправитель целиком:
|
||||
@@ -317,9 +418,11 @@ type pointHead struct {
|
||||
// мгновенно. Разбор `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 // причина уходит в лог, наружу не раскрывается
|
||||
func decodeEnvelope(body []byte) (envelope, error) {
|
||||
var env envelope
|
||||
|
||||
fail := func(e error) (envelope, error) {
|
||||
return envelope{}, fmt.Errorf("%w: %v", ErrMalformed, e) //nolint:errorlint // причина уходит в лог, наружу не раскрывается
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(bytes.NewReader(body))
|
||||
@@ -335,7 +438,7 @@ func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string,
|
||||
// раскладывал его в пустую структуру. Границы поведения этой задачей не
|
||||
// двигаются — она добавляет список, а не строгость.
|
||||
if tok == nil {
|
||||
return nil, nil, 0, nil
|
||||
return envelope{}, nil
|
||||
}
|
||||
if d, ok := tok.(json.Delim); !ok || d != '{' {
|
||||
return fail(fmt.Errorf("ожидался объект, встречено %v", tok))
|
||||
@@ -352,8 +455,12 @@ func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string,
|
||||
}
|
||||
continue
|
||||
}
|
||||
metrics, uncovered, dropped, err = decodeData(dec, seen)
|
||||
if err != nil {
|
||||
// Повтор самого члена `data` JSON допускает, и результаты
|
||||
// НАКАПЛИВАЮТСЯ, а не замещаются: присваивание теряло бы секции первого
|
||||
// члена целиком, причём молча — их имена уже отмечены в `seen` и во
|
||||
// второй список непокрытых не попали бы. Правило то же, что уровнем
|
||||
// ниже для повтора ключа секции.
|
||||
if err := decodeData(dec, seen, &env); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
}
|
||||
@@ -363,60 +470,75 @@ func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string,
|
||||
|
||||
// Список канонизируется: порядок ключей в JSON от HAE нестабилен, а
|
||||
// значение уезжает в базу и сравнивается между доставками.
|
||||
sort.Strings(uncovered)
|
||||
return metrics, uncovered, dropped, nil
|
||||
sort.Strings(env.uncovered)
|
||||
return env, nil
|
||||
}
|
||||
|
||||
// decodeData разбирает объект data, собирая metrics и имена непокрытых секций.
|
||||
func decodeData(dec *json.Decoder, seen map[string]struct{}) ([]metricEnvelope, []string, int, error) {
|
||||
// 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 {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return nil, nil, 0, err
|
||||
return err
|
||||
}
|
||||
// data не объект — прежнее поведение: ошибка ровно там, где была.
|
||||
if d, ok := tok.(json.Delim); !ok || d != '{' {
|
||||
return nil, nil, 0, fmt.Errorf("data: ожидался объект, встречено %v", tok)
|
||||
return 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
|
||||
return 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...)
|
||||
handled, err := decodeCovered(name, dec, env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if handled {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := swallow(dec); err != nil {
|
||||
return nil, nil, 0, err
|
||||
return err
|
||||
}
|
||||
if _, dup := seen[name]; dup {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
if len(uncovered) >= maxUncovered {
|
||||
dropped++
|
||||
if len(env.uncovered) >= maxUncovered {
|
||||
env.dropped++
|
||||
continue
|
||||
}
|
||||
uncovered = append(uncovered, clipSection(name))
|
||||
env.uncovered = append(env.uncovered, clipSection(name))
|
||||
}
|
||||
if _, err := dec.Token(); err != nil { // закрывающая скобка data
|
||||
return nil, nil, 0, err
|
||||
return err
|
||||
}
|
||||
return metrics, uncovered, dropped, nil
|
||||
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() отдаёт имя уже после разбора
|
||||
|
||||
Reference in New Issue
Block a user