Files
healthlog/openspec/changes/archive/2026-08-01-razbor-metrik-v-obekty/design.md
T
av 37413bb551 change razbor-metrik-v-obekty заархивирован
Дельты влиты в openspec/specs (parsing, storage), задача убрана из беклога,
план отражает сделанную часть шага 3.

Не закрыт один пункт: живая доставка с телефона не разобрана — поток молчит
с 17:13, пауза началась до перезапуска сервиса.
2026-08-01 19:03:46 +03:00

287 lines
23 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 ничего не знает.
**Стратегия декодирования — часть контракта, а не деталь.** Конверт
разбирается в структуру с `Data []json.RawMessage` на метрику; точка
декодируется по одной и сразу отбрасывается. Измерено: разбор тела 42 МиБ в
`map[string]any` удерживает 197 МиБ кучи против 54 МиБ у этой формы. Вместе с
самим телом и удвоением в чтении пик доходит до ~300 МиБ на доставку — при
трёх автоматизациях и неизвестном размере VPS это OOM ровно на пике потока,
когда терять доставки дороже всего.
Сигнатура при этом остаётся `[]byte`: тело уже целиком в памяти после чтения
запроса, `io.Reader` добавил бы второй буфер и ничего не сэкономил, а правило
вывода слоя (≥10 точек по всей доставке) всё равно требует двух проходов.
### Дом канонизации — общий, а не внутри разбора
Канонизация, полнота точки и хеш живут в **нейтральном** пакете, который
импортируют и `hae`, и `store`. Иначе граница «`store` о HAE не знает»
оставляет их без дома: слияние происходит в `store`, после него хеш надо
пересчитать, а канонизация лежала бы в `hae`.
Альтернатива — вторая реализация канонизации для импорта родного экспорта
Apple — отвергнута: две реализации разойдутся на дребезге последнего разряда
double, и хеш-детектор начнёт видеть изменения там, где их нет. Глубокий
проход из почти бесплатного превратится в перезапись недели на каждом прогоне.
Каноническая форма существует только в момент вычисления хеша. Хранимая форма
— исходные байты точки: числа читаются литералом (`json.Number`), потому что
обход через `float64` теряет `1.0``1` и сдвигает целые больше 2^53, а
невалидный UTF-8 в именах устройств заменяется на U+FFFD. Сортировку ключей
делает `encoding/json`, своей писать не надо; собственным остаётся округление
до двенадцати значащих цифр.
### Разбор — функция от доставки в архиве, а не от тела в памяти
Разбор адресуется **идентификатором доставки**, тело читается из архива. Приём
сворачивает одну доставку, будущий `reindex` — все; код один.
Первая редакция дизайна отвергала это как «асинхронный разбор с очередью»,
которым оно не является: проход по архиву — детерминированная свёртка, ровно
то, чем система объявлена в `docs/architecture.md`
(`состояние = import(снапшот) + replay(доставки)`). Прежняя форма давала два
кода для одной операции — разбор при приёме и будущую пересборку, — и они
разошлись бы на первом же расхождении.
Плата: тело перечитывается с диска сразу после записи. Для 42 МБ это
страничный кэш, то есть несущественно.
### Свёртка вызывается синхронно, сразу после записи в архив
Тело ложится на диск, потом сворачивается — но уже как доставка из архива, а
не как буфер в памяти (см. выше). Отказ свёртки не откатывает архив: журнал
важнее витрины, восстановить точки из тела можно всегда, тело из точек — нет.
Синхронно, а не фоновым воркером: очередь дала бы окно «принято, но не
свёрнуто» при перезапуске, и понадобилось бы отдельное состояние «что
досворачивать». Свёртка одной доставки стоит секунд, а `read_timeout` уже пять
минут.
Работа после записи в архив идёт на контексте, **отвязанном от запроса**
(`context.WithoutCancel` с собственным дедлайном): иначе обрыв соединения
клиентом или Caddy на середине свёртки оставит часть объектов записанной, а
доставку — со статусом, по которому её никто не подберёт.
### Ключ объекта — `(metric, layer, hour_utc)`, содержимое — gzip-BLOB
Единица хранения — час, а не точка: 30 метрик × 24 часа × 365 ≈ 260 тыс. строк
на слой в год независимо от плотности точек внутри. Строка на точку дала бы
десятки миллионов.
Плата: внутрь объекта не заглянуть средствами SQL. Для хранилища, отдающего
диапазоны точек, это не потеря; каталог и свёртка получат свои производные
структуры отдельной задачей.
Сжатие наблюдалось около 25 раз — ~2 МБ в сутки вместо ~50 МБ.
### Координата — интервал, у измерения вырожденный
Ключ `метрика + слой + метка` верен для точки-измерения и неверен для
точки-интервала: под одной меткой лежит до трёх записей сна. Ключ единый:
```
координата = метрика + слой + начало + конец конец = начало, если end нет
```
Замер по 94 доставкам (находка 47): метка одна даёт 170 координат сна и 33
столкновения **внутри одной доставки**, интервал — 174 и ноль; `value` в ключе
не добавляет ни одной координаты.
Первая редакция вводила отдельный класс «эпизодных схем» с признаком «`start` и
`end`, отличные от `date`». Перепись по всем 22 метрикам с интервалами его
опровергла:
- **`start` всегда равен `date`** — признак не сработал бы ни разу;
- **интервалы несёт не только сон**, а 22 метрики, так что «эпизодная схема» —
не класс, а норма;
- **обе формы точки не смешиваются** внутри метрики одной доставки, поэтому
единый ключ не разорвёт надвое точку, приехавшую то с `end`, то без;
- **разные интервалы под одной меткой всегда несут разное содержимое**
(проверено по всем метрикам), так что ключ с интервалом не задваивает
поправленное задним числом.
Одна форма ключа вместо двух убирает из кода ветвление и понятие, которое
пришлось бы поддерживать в каталоге, Read API и импорте экспорта Apple.
Почему не «принять потерю и писать `WARN`»: в дублях внутри одной доставки
`received_at` общий, и тай-брейк по времени приёма неприменим в принципе —
исход решал бы порядок элементов в JSON-массиве, а он нестабилен (находка 2).
Свёртка перестала бы быть детерминированной: пересборка из архива давала бы не
то состояние, что живой приём.
Почему не append-only по хешу содержимого: это вторая модель идентичности в
`store` ради случая, которого можно избежать, и эпизоды не схлопывались бы
никогда — даже когда повтор действительно повтор, а их здесь 1706 из 1880.
Проверено против чужих решений (находка 47): единственная принятая схема
дедупликации Apple Health — `Start + End + тип`, без источника в ключе;
популярные ингесторы поверх InfluxDB ключуют по метке и теряют эпизоды
молчаливым last-write-wins движка. `HKObject.uuid` дал бы идентичность даром,
но **в выгрузку Apple он не попадает** — значит модель обязана выражаться через
`start`/`end`, иначе `import(экспорт)` не сойдётся с `replay(HAE)`.
### Слияние — по полноте, при равенстве — по `received_at`
Координатный ключ означает перезапись значения. Кто побеждает — решает
полнота: 0.66% координат несут разные содержимые, и разбор выборки показал,
что почти всё это разный **набор полей** при одинаковом `qty`. Правило «последний
победил» стирало бы `start`/`end` у уже сохранённой точки.
Полнота считается по числу значащих полей точки, `source` в счёт не идёт.
При **равной** полноте исход обязан быть детерминированным и не зависеть от
порядка доставок. Первая редакция предписывала сравнение по `received_at`
оно неисполнимо: у сохранённой точки нет провенанса, сравнивать не с чем. Хуже,
что четверть доставок несёт столкновения **внутри себя**, где `received_at`
вообще один. Поэтому исход определяется свойством самих значений (порядком
канонических форм), а не порядком событий.
Столкновение с различием содержимого оставляет след — `WARN` и счётчик. Иначе
допущение «меньше полей не значит новее», объявленное риском, не получит ни
одного наблюдения.
### Слой выводится по метрике внутри доставки, а не по доставке целиком
Правило проверено на всей истории (находка 33) и уже дважды ломалось на живых
данных при более простых формулировках. Классификация доставки целиком
сложила минутные точки с посекундными и удвоила сумму за час; классификация
каждой метрики по отдельности растащила редкие метрики по трём слоям.
Работающая формулировка: плотная метрика (≥10 точек) — сама по себе, редкая
наследует самый мелкий слой среди плотных.
Третий шаг — доставка без плотных метрик вовсе — наследует последний надёжно
выведенный слой той же автоматизации. Первая редакция заменила его заголовком,
и это была регрессия: измерено 2 такие доставки из 89, обе с заголовком
`Default`, который не означает режима. Наследовать нечего и заголовок
ненадёжен — точки не сохраняются, доставка ждёт пересборки; молчаливый `raw`
создал бы призрачный разрез, который поедет в каталог и в выбор слоя Read API.
### Сводка сна — отдельное имя метрики и фиксированный слой `day`
Разводить схемы на имена приходится потому, что правило вывода слоя на суточной
сводке даёт `hour` (полночь выровнена по часу), хотя это суточный итог. Имя
`sleep_analysis_summary` — наше, не Apple; инвариант «форма Apple не
транслируется» это не нарушает: переименования полей внутри точки нет,
разделяются только имена метрик, под которыми HAE смешал две схемы.
### `testdata` — реальные пакеты с вычищенными значениями
Конвенция требует тестов на реальных пакетах; инвариант запрещает данным о
здоровье попадать под контроль версий. Обе цели совместимы: в фикстурах
сохраняется всё, что важно разбору, — порядок ключей, три формата времени,
неразрывные пробелы в именах устройств, точность чисел, обе схемы сна,
смешанная доставка, — а измеренные величины заменяются.
Скрипт порождения фикстур из архива лежит в `tmp/research/` и позволяет собрать
их заново, когда поток принесёт новую форму.
Альтернатива — писать фикстуры руками по документации. Отвергнута ровно тем,
ради чего заводилось исследование: документация врёт.
## Risks / Trade-offs
**Разбор роняет приём** → разбор идёт после `f.Sync()` и переименования файла
архива; паника в разборе перехватывается, доставка помечается
`parse_status=failed`, ответ остаётся `200`.
**Конкурентные доставки правят один час** → read-modify-write теряет точки, и
наивное «транзакция плюс повтор» **измеримо не работает**. Замер на
`modernc.org/sqlite` с DSN проекта, 4 горутины × 200 слияний в одну строку:
```
txlock=deferred без повтора 91 из 800
txlock=deferred с повтором 242 из 800 (31078 повторов)
txlock=immediate без повтора 800 из 800
```
Причина: код отказа — `517` (`SQLITE_BUSY_SNAPSHOT`), и `busy_timeout` его не
покрывает, SQLite возвращает его немедленно. Поэтому: `_txlock=immediate` в
DSN; повтор оборачивает **всю тройку** чтение-слияние-запись, а не только
запись (иначе повтор перезапишет чужие точки уже прочитанным состоянием —
классический lost update); путь «хеш совпал, писать нечего» идёт под
`TxOptions{ReadOnly: true}`, чтобы не сериализоваться на write-lock;
распознавание — `errors.As` на `*sqlite.Error` с кодами 5 и 517, обёрнутое в
`store`, чтобы драйвер не торчал наружу.
Тест обязан быть с настоящей конкуренцией и проверкой суммы: две горутины в
удачном порядке проходят и на сломанной реализации.
**Правило полноты ошибочно для метрики, где меньше полей значит новее**
таких в потоке не наблюдалось, но допущение не доказано. Помечается как
предположение в спеке; расхождение всплывёт при сверке с экспортом Apple.
**Вычищенные фикстуры прячут свойство реальных данных** → риск реален: именно
дребезг последнего разряда double едва не увёл модель идентичности не туда.
Смягчение — сохранять точность чисел как в оригинале и держать отдельный тест
на канонизацию с настоящими значениями из находки 30.
**Объект за час распухает** → в нижнем слое HRV несёт `heartbeatSeries`, 93%
объёма метрики. Порог не выбран, поведение при большом объекте не определено;
наблюдаемость размера объекта уходит в задачу про `/stats`.
## Migration Plan
Миграция `00003_bucket.sql` — только добавление таблицы, существующие данные не
трогает. Откат: сервис прежней версии игнорирует новую таблицу, доставки
продолжают приниматься и складываться в архив, разбор просто не происходит.
Накопленные 89 доставок этой миграцией не разбираются: их подхватит `reindex`
отдельной задачей. До тех пор в хранилище только точки из доставок, пришедших
после выката.
## Open Questions
- **Порог `sealed`.** С какого возраста час считается запечатанным — ставим по
факту: сначала `WARN` на изменение старых объектов, потом смотрим, какая
глубина досчёта встречается в жизни (наблюдалось до 22 минут).
- **Поведение при объекте необычного размера.** Отдельного решения пока нет;
ждём наблюдаемости.