добавлен словарь категориальных значений HAE → коды HealthKit
- фазы сна, контекст пульса и имена тренировок попадают в реестр `category_value` (миграция 00010): строка хранится дословно, выведенный код лежит рядом отдельной записью, а не полем внутри точки - словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в сыром архиве нет - наблюдение входит в отпечаток витрины, выведенный код — нет: он производная от словаря, а не от журнала
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user