## 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 доставок доедут отдельной задачей. - Словарь категориальных значений: строка пока хранится дословно и без кода. - Род агрегации и каталог разрезов. - Своя агрегация при записи: слои не сводятся друг к другу никогда. - **Хранение эпизодных схем** (поэпизодный `sleep_analysis`): модель их идентичности вынесена блокером `identichnost-epizodnyh-metrik`. Точки разбираются и считаются, но не сохраняются; тела в архиве, подберёт пересборка. ## 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 МБ. ### Слияние — по полноте, при равенстве — по `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 минут). - **Поведение при объекте необычного размера.** Отдельного решения пока нет; ждём наблюдаемости.