- находка 47: ключ (date,start,end) даёт 174 координаты сна против 170 по метке и ноль столкновений внутри доставки против 33; value в ключе лишний - UUID в выгрузку Apple не попадает, другой идентичности у эпизода нет - поэпизодный sleep_analysis вернулся в scope razbor-metrik-v-obekty
23 KiB
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 МБ.
Эпизод адресуется интервалом, а не меткой
Координата метрика + слой + метка верна для точки-измерения и неверна для
точки-интервала: под одной меткой лежит до трёх разных эпизодов сна. Поэтому
у точки, несущей start и end, координата — метрика + слой + start + end.
Замер по всем 94 доставкам (1880 эпизодных точек, находка 47): метка одна даёт
170 координат и 33 столкновения внутри одной доставки, интервал — 174
координаты и ноль столкновений; value в ключе не добавляет ни одной
координаты. Из 174 координат ни одна не несёт двух разных содержимых, то есть
на эпизодном сне правило разрешения столкновений не срабатывает ни разу.
Эпизодность выводится из данных, а не курируется списком — так же, как слой
выводится из выравнивания меток, а не из заголовка. Признак: точка несёт start
и end, отличные от date. Список имён метрик здесь был бы вторым способом
описывать то, что уже сказано формой точки, и разошёлся бы с ней на первой же
новой метрике HAE.
Почему не «принять потерю и писать 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 минут). - Поведение при объекте необычного размера. Отдельного решения пока нет; ждём наблюдаемости.