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

158 lines
12 KiB
Markdown
Raw 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.
## 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 минут).
- **Поведение при объекте необычного размера.** Отдельного решения пока нет;
ждём наблюдаемости.