Files
healthlog/openspec/changes/razbor-metrik-v-obekty/design.md
T
av c6f27e3890 заведён change на разбор метрик в часовые объекты
- proposal и две capability: parsing (вывод слоя, форматы времени, канонизация)
  и storage (координатный ключ, слияние по полноте, часовые объекты)
- design фиксирует границы: разбор отдельным пакетом, синхронно после записи в
  архив, конкурентная запись в один час — риск с тестом
- вне scope сознательно: тренировки, reindex, словарь кодов, род агрегации
2026-08-01 14:50:10 +03:00

12 KiB
Raw Blame History

Context

Приём принимает пакеты Health Auto Export и складывает тела в архив (internal/ingest, internal/archive). Разбора нет: в SQLite только строки delivery. Накоплено 89 доставок, 16 МБ архива, поток идёт непрерывно.

Правила разбора выведены измерением, а не спроектированы: 46 находок в docs/local-research.md. Половина расходится с документацией HAE, поэтому источник истины по формату — живые пакеты, а не документация.

Ограничение, определяющее форму решения: сервис нельзя останавливать. Телефон шлёт непрерывно и молча; доставка, не попавшая в архив, не попадает в журнал вовсе — телефон её не перешлёт. Значит разбор не имеет права уронить приём.

Goals / Non-Goals

Goals:

  • Точки из секции metrics попадают в хранилище с правильным слоем.
  • Повторные и пересекающиеся доставки не задваивают и не затирают данные.
  • Разбор отделён от хранения: импорт родного экспорта Apple будет другим разбором поверх того же хранилища.
  • Ошибка разбора не влияет ни на код ответа приёма, ни на сохранность архива.

Non-Goals:

  • Тренировки и секции с собственными id — другая модель хранения.
  • healthlog reindex — накопленные 89 доставок доедут отдельной задачей.
  • Словарь категориальных значений: строка пока хранится дословно и без кода.
  • Род агрегации и каталог разрезов.
  • Своя агрегация при записи: слои не сводятся друг к другу никогда.

Decisions

Разбор — отдельный пакет internal/hae, а не метод ingest

ingest — use-case приёма: сохранить тело, записать доставку. Разбор формата живёт своей жизнью: у него будет второй потребитель (reindex) и второй источник (родной экспорт Apple, свой пакет). Втянуть разбор в ingest значит получить пакет, который меняется по двум несвязанным причинам.

Альтернатива — разбор внутри store. Отвергнута: store не должен знать формат HAE, иначе импорт из Apple потребует второй реализации хранения.

Граница: hae.Parse(body []byte, hdr Meta) (Parsed, error) возвращает точки с уже выведенным слоем и нормализованным временем. Дальше их принимает store, который о HAE ничего не знает.

Разбор — синхронно в приёме, после записи в архив

Тело сначала ложится на диск, потом разбирается. Отказ разбора не откатывает архив: журнал важнее витрины, и восстановить точки из тела можно всегда, а тело из точек — нет.

Асинхронный разбор (очередь, воркер) отвергнут: он даёт окно, в котором доставка принята, но не разобрана, а сервис перезапущен — и мы теряем понимание, что доразобрать. Синхронный разбор при 42 МБ теле стоит секунд, а read_timeout уже пять минут.

Ключ объекта — (metric, layer, hour_utc), содержимое — gzip-BLOB

Единица хранения — час, а не точка: 30 метрик × 24 часа × 365 ≈ 260 тыс. строк на слой в год независимо от плотности точек внутри. Строка на точку дала бы десятки миллионов.

Плата: внутрь объекта не заглянуть средствами SQL. Для хранилища, отдающего диапазоны точек, это не потеря; каталог и свёртка получат свои производные структуры отдельной задачей.

Сжатие наблюдалось около 25 раз — ~2 МБ в сутки вместо ~50 МБ.

Слияние — по полноте, при равенстве — по received_at

Координатный ключ означает перезапись значения. Кто побеждает — решает полнота: 0.66% координат несут разные содержимые, и разбор выборки показал, что почти всё это разный набор полей при одинаковом qty. Правило «последний победил» стирало бы start/end у уже сохранённой точки.

Полнота считается по числу значащих полей точки, source в счёт не идёт. При равной полноте побеждает точка из доставки с большим received_at — это делает свёртку по журналу детерминированной: проигрывание архива обязано дать то же состояние, что приём в реальном времени.

Слой выводится по метрике внутри доставки, а не по доставке целиком

Правило проверено на всей истории (находка 33) и уже дважды ломалось на живых данных при более простых формулировках. Классификация доставки целиком сложила минутные точки с посекундными и удвоила сумму за час; классификация каждой метрики по отдельности растащила редкие метрики по трём слоям.

Работающая формулировка: плотная метрика (≥10 точек) — сама по себе, редкая наследует самый мелкий слой среди плотных.

Сводка сна — отдельное имя метрики и фиксированный слой day

Разводить схемы на имена приходится потому, что правило вывода слоя на суточной сводке даёт hour (полночь выровнена по часу), хотя это суточный итог. Имя sleep_analysis_summary — наше, не Apple; инвариант «форма Apple не транслируется» это не нарушает: переименования полей внутри точки нет, разделяются только имена метрик, под которыми HAE смешал две схемы.

testdata — реальные пакеты с вычищенными значениями

Конвенция требует тестов на реальных пакетах; инвариант запрещает данным о здоровье попадать под контроль версий. Обе цели совместимы: в фикстурах сохраняется всё, что важно разбору, — порядок ключей, три формата времени, неразрывные пробелы в именах устройств, точность чисел, обе схемы сна, смешанная доставка, — а измеренные величины заменяются.

Скрипт порождения фикстур из архива лежит в tmp/research/ и позволяет собрать их заново, когда поток принесёт новую форму.

Альтернатива — писать фикстуры руками по документации. Отвергнута ровно тем, ради чего заводилось исследование: документация врёт.

Risks / Trade-offs

Разбор роняет приём → разбор идёт после f.Sync() и переименования файла архива; паника в разборе перехватывается, доставка помечается parse_status=failed, ответ остаётся 200.

Конкурентные доставки правят один час → read-modify-write без блокировки теряет точки. Три автоматизации шлют одновременно, и перекрытие часов — норма, а не край. Запись объекта идёт в транзакции; при SQLITE_BUSY — повтор. Проверяется тестом с параллельной записью в один hour_utc.

Правило полноты ошибочно для метрики, где меньше полей значит новее → таких в потоке не наблюдалось, но допущение не доказано. Помечается как предположение в спеке; расхождение всплывёт при сверке с экспортом Apple.

Вычищенные фикстуры прячут свойство реальных данных → риск реален: именно дребезг последнего разряда double едва не увёл модель идентичности не туда. Смягчение — сохранять точность чисел как в оригинале и держать отдельный тест на канонизацию с настоящими значениями из находки 30.

Объект за час распухает → в нижнем слое HRV несёт heartbeatSeries, 93% объёма метрики. Порог не выбран, поведение при большом объекте не определено; наблюдаемость размера объекта уходит в задачу про /stats.

Migration Plan

Миграция 00003_bucket.sql — только добавление таблицы, существующие данные не трогает. Откат: сервис прежней версии игнорирует новую таблицу, доставки продолжают приниматься и складываться в архив, разбор просто не происходит.

Накопленные 89 доставок этой миграцией не разбираются: их подхватит reindex отдельной задачей. До тех пор в хранилище только точки из доставок, пришедших после выката.

Open Questions

  • Порог sealed. С какого возраста час считается запечатанным — ставим по факту: сначала WARN на изменение старых объектов, потом смотрим, какая глубина досчёта встречается в жизни (наблюдалось до 22 минут).
  • Поведение при объекте необычного размера. Отдельного решения пока нет; ждём наблюдаемости.