Files
av 1b649ba3d5 добавлен словарь категориальных значений HAE → коды HealthKit
- фазы сна, контекст пульса и имена тренировок попадают в реестр
  `category_value` (миграция 00010): строка хранится дословно, выведенный код
  лежит рядом отдельной записью, а не полем внутри точки
- словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из
  `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в
  сыром архиве нет
- наблюдение входит в отпечаток витрины, выведенный код — нет: он производная
  от словаря, а не от журнала
2026-08-04 07:32:19 +03:00

258 lines
14 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
import (
"encoding/json"
"sort"
"strings"
"unicode/utf8"
"git.vakhrushev.me/av/healthlog/internal/healthkit"
)
// Categorical — наблюдённое категориальное значение: строка перечислимого поля
// и выведенный для неё код HealthKit.
//
// Это НАБЛЮДЕНИЕ, а не копия данных: сама строка остаётся в содержимом точки
// дословно, а наблюдение говорит «такое значение поток приносил, и вот его
// стабильный код». Пустой код означает «словарь этой строки не знает» — и
// перечень таких строк есть заявка на пополнение словаря.
type Categorical struct {
// Metric — имя метрики или секции, ТО ЖЕ, которым адресуется единица
// хранения (`sleep_analysis_summary` после разделения схем, `workouts` у
// тренировок). Второе имя для того же понятия развело бы наблюдение и объект
// по разным ключам.
Metric string
// Field — имя поля внутри точки или сущности, дословно как у HAE.
Field string
// Value — строка, как прислал HAE.
Value string
// Code — канонический код HealthKit либо пустая строка.
Code string
}
// Имена категориальных полей — дословно как у HAE.
const (
fieldValue = "value"
fieldContext = "context"
fieldName = "name"
)
// heartRateMetric — имя метрики пульса у HAE.
const heartRateMetric = "heart_rate"
// pointCategoricalFields — какие поля ТОЧКИ несут перечислимое значение.
//
// Список объявлен явно, а не выведен из формы значения: строк в точке много
// (`date`, `start`, `source`), и правило «всякая строка категориальна» завело бы
// в реестр метки времени и имена устройств. Состав измерен на живом потоке
// (находка 37); новое поле — одна строка здесь и запись в словаре.
var pointCategoricalFields = map[string][]string{
sleepMetric: {fieldValue},
heartRateMetric: {fieldContext},
}
// Границы наблюдений одной доставки.
//
// Тело контролирует отправитель целиком: без границы одна доставка кладёт в
// витрину сколько угодно строк, а строки эти уезжают в первичный ключ.
//
// Числа названы измерением, а не аналогией с 32/64 у имён непокрытых секций:
// там имена короткие и латинские, здесь — русские фразы в UTF-8. На живом
// потоке различных значений по всем трём полям около одиннадцати, самое длинное
// — «Сидячий образ жизни», 36 байт (находка 37). Предел в 32 байта отбросил бы
// две из трёх измеренных строк контекста пульса.
const (
maxCategoricalValues = 64
maxCategoricalValueLen = 128
)
// categoricalKey — ключ наблюдения. Совпадает с ключом реестра в витрине: два
// разных ключа на одно понятие разошлись бы при первом же поле с одинаковым
// именем у двух метрик.
type categoricalKey struct {
metric string
field string
value string
}
// categoricals — сборщик наблюдений одной доставки.
//
// Дедупликация обязательна: `context` повторяется в каждой точке пульса, и без
// множества доставка на две тысячи точек дала бы две тысячи одинаковых
// наблюдений. Локаль хранится здесь, а не в наблюдении: она сужает поиск по
// словарю и в ключ не входит — заголовков в сыром архиве нет, и ключ с локалью
// сделал бы состояние функцией от того, уцелела ли учётная строка.
//
// Набор ограничен ПРИ ВСТАВКЕ, а не при выдаче, и это измеренное решение, а не
// аккуратность. Накопитель без границы растёт по числу РАЗЛИЧНЫХ строк тела, а
// их контролирует отправитель: тело в 60 МиБ из миллиона различных значений
// (предел приёма — 64 МиБ) поднимало пик процесса с 780 до 1002 МиБ. Лимита
// памяти у контейнера нет, OOM в фоновой горутине свёртки не перехватывается, а
// `restart: unless-stopped` поднимает процесс — и первый же проход берёт ту же
// доставку из архива. Приём при этом стоит, а телефон доставку не перешлёт.
// Правило то же, что уже действует в этом пакете для имён непокрытых секций.
//
// Держатся 64 НАИМЕНЬШИХ ключа: усечение остаётся функцией МНОЖЕСТВА, а не
// порядка элементов на проводе. Порядок ключей у HAE нестабилен (находка 2), и
// «первые 64 по ходу разбора» давали бы разный реестр на переприсланном том же
// содержимом — расхождение вышло бы как «пересборка не сошлась», без адреса.
//
// Срез, а не куча: элементов 64, вставка двоичным поиском стоит дешевле
// поддержания инварианта кучи, а отсортированный срез заодно и есть готовый
// ответ `result`.
type categoricals struct {
locale string
// seen — членство, kept — те же ключи в порядке возрастания. Две структуры
// на одно множество: карта отвечает «видели ли», срез — «кто наибольший»,
// и оба вопроса задаются на каждое вхождение.
seen map[categoricalKey]struct{}
kept []categoricalKey
dropped int
}
func newCategoricals(locale string) *categoricals {
return &categoricals{
locale: locale,
seen: make(map[categoricalKey]struct{}, maxCategoricalValues),
kept: make([]categoricalKey, 0, maxCategoricalValues),
}
}
// less задаёт порядок ключей — он же порядок выдачи и он же правило усечения.
func (a categoricalKey) less(b categoricalKey) bool {
if a.metric != b.metric {
return a.metric < b.metric
}
if a.field != b.field {
return a.field < b.field
}
return a.value < b.value
}
// add записывает наблюдение, если строка на него годится.
//
// Пустая строка наблюдением не считается: сказать о данных ей нечего, а в
// счётчике строк без кода она сидела бы вечно — тренировка без `name` даёт
// ровно её (мягкое чтение заголовка сущности превращает значение не того типа в
// пустую строку).
//
// Слишком длинное значение ОТБРАСЫВАЕТСЯ, а не обрезается: обрезанная строка
// неотличима от настоящей и попала бы в ключ реестра самостоятельным значением.
// Сама точка при этом хранится целиком — теряется наблюдение, а не данные.
func (c *categoricals) add(metric, field, value string) {
if value == "" {
return
}
if len(value) > maxCategoricalValueLen {
c.dropped++
return
}
key := categoricalKey{metric: metric, field: field, value: value}
if _, ok := c.seen[key]; ok {
return
}
if len(c.kept) >= maxCategoricalValues {
// Набор полон. Ключ больше наибольшего удержанного — он и есть
// отброшенный; иначе вытесняем наибольший, а отброшенным становится он.
last := c.kept[len(c.kept)-1]
if !key.less(last) {
c.dropped++
return
}
delete(c.seen, last)
c.kept = c.kept[:len(c.kept)-1]
c.dropped++
}
at := sort.Search(len(c.kept), func(i int) bool { return key.less(c.kept[i]) })
c.kept = append(c.kept, categoricalKey{})
copy(c.kept[at+1:], c.kept[at:])
c.kept[at] = key
c.seen[key] = struct{}{}
}
// addPoint снимает с точки объявленные для её метрики поля.
//
// Значение читается из уже разобранного заголовка точки и принимается только
// как JSON-строка: объяви поле `string` в самом заголовке — и точка, у которой
// `value` пришло числом, перестала бы разбираться вовсе. Это был бы новый путь
// потери данных ради удобства структуры.
func (c *categoricals) addPoint(metric string, head *pointHead) {
for _, field := range pointCategoricalFields[metric] {
var raw *json.RawMessage
switch field {
case fieldValue:
raw = head.Value
case fieldContext:
raw = head.Context
}
if raw == nil {
continue
}
if s, ok := jsonString(*raw); ok {
c.add(metric, field, s)
}
}
}
// result отдаёт наблюдения доставки: отсортированные, усечённые границей и с
// выведенными кодами.
//
// Порядок и усечение — функция МНОЖЕСТВА, а не порядка элементов на проводе; за
// это отвечает add, здесь набор уже готов.
//
// Код выводится здесь, а не при добавлении: словарь зовётся по разу на
// РАЗЛИЧНОЕ удержанное значение, а не по разу на точку. На теле в миллион
// точек это 64 обращения к карте вместо миллиона.
func (c *categoricals) result() (out []Categorical, unknown, dropped int) {
dropped = c.dropped
out = make([]Categorical, 0, len(c.kept))
for _, k := range c.kept {
code := healthkit.Code(c.locale, k.value)
if code == "" {
unknown++
}
out = append(out, Categorical{Metric: k.metric, Field: k.field, Value: k.value, Code: code})
}
return out, unknown, dropped
}
// jsonString читает значение как строку JSON, не считая строкой ничего другого.
//
// Проверка первого байта — та же дисциплина, что в jsonNumber: полагаться на
// тип-приёмник значило бы получить разное поведение от невидимой детали, а
// молчаливое приведение числа к строке выдумало бы за источник значение,
// которого он не присылал.
func jsonString(raw json.RawMessage) (string, bool) {
if len(raw) == 0 || raw[0] != '"' {
return "", false
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return "", false
}
// Значение, которое `encoding/json` ЗАМЕНИЛ, наблюдением не считается.
//
// Ошибки он на этом не даёт: негодную последовательность — сырой байт 0xFF,
// одинокий суррогат `\ud800` — он молча меняет на U+FFFD, и строка на выходе
// оказывается валидным UTF-8, но уже не равной пришедшим байтам (измерено:
// 0xFF даёт "\uFFFDВо сне", `utf8.ValidString` отвечает true). Проверять
// поэтому надо не годность результата, а его НЕТРОНУТОСТЬ.
//
// Реестр требует хранить значение дословно; подменённое осело бы в первичном
// ключе таблицы, у которой нет обслуживания, и кода не получило бы никогда.
// Точка при этом хранится целиком — теряется наблюдение, а не данные, и это
// та же цена, что у непомерной длины.
//
// Цена правила названа: настоящий U+FFFD в значении тоже не станет
// наблюдением. Перечислимые значения HealthKit — слова человеческого языка,
// символа замены в них не бывает, а ошибка направлена в безопасную сторону.
if strings.ContainsRune(s, utf8.RuneError) {
return "", false
}
return s, true
}