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 }