change razbor-metrik-v-obekty заархивирован

Дельты влиты в openspec/specs (parsing, storage), задача убрана из беклога,
план отражает сделанную часть шага 3.

Не закрыт один пункт: живая доставка с телефона не разобрана — поток молчит
с 17:13, пауза началась до перезапуска сервиса.
This commit is contained in:
av
2026-08-01 19:03:46 +03:00
parent cd7a4c1493
commit 37413bb551
11 changed files with 487 additions and 41 deletions
@@ -0,0 +1,286 @@
## 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 минут).
- **Поведение при объекте необычного размера.** Отдельного решения пока нет;
ждём наблюдаемости.