заведён change на разбор метрик в часовые объекты
- proposal и две capability: parsing (вывод слоя, форматы времени, канонизация) и storage (координатный ключ, слияние по полноте, часовые объекты) - design фиксирует границы: разбор отдельным пакетом, синхронно после записи в архив, конкурентная запись в один час — риск с тестом - вне scope сознательно: тренировки, reindex, словарь кодов, род агрегации
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
## 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 минут).
|
||||
- **Поведение при объекте необычного размера.** Отдельного решения пока нет;
|
||||
ждём наблюдаемости.
|
||||
Reference in New Issue
Block a user