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

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

23 KiB
Raw Permalink Blame History

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.01 и сдвигает целые больше 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 минут).
  • Поведение при объекте необычного размера. Отдельного решения пока нет; ждём наблюдаемости.