Files
healthlog/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/design.md
T
av f8200f7f80 feat: разбор и хранение тренировок и состояния разума
- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной
  строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`;
  миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки
  с этими ключами
- сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет
  содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а
  при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА —
  «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина
  расходилась бы с пересборкой молча
- отпечаток витрины покрывает тренировки и записи и снимается одним снимком
  базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
2026-08-02 13:05:16 +03:00

36 KiB
Raw Blame History

Context

Разбор покрывает одну секцию тела — metrics. Остальное перечисляется в delivery.uncovered_sections, доставка получает partial, тело живёт в архиве. Замер по 118 доставкам архива: metrics — 65 доставок, workouts — 27, stateOfMind — 26; ни одна доставка не несла двух секций сразу.

Тренировка и состояние разума устроены иначе, чем метрика, и это не стилистика:

  • у них есть собственный id (UUID из HealthKit) — координатный ключ метрика + слой + начало + конец им не нужен;
  • они редки: за двое суток потока — 2 разных тренировки и 2 разных записи состояния разума, при 44 и 52 доставленных копиях соответственно;
  • у них нет слоя: подробности выгрузки у этих секций в интерфейсе HAE не бывает, есть только глубина окна;
  • тренировка тяжёлая: маршрут — 95% её веса (190 КБ из 199,6 КБ у десятиминутной прогулки), и приезжает она повторно, пока маршрут не доедет.

Замер поведения при переприсылке (тот же архив, группировка по id):

тренировка A   26 копий   3 различных содержимых   поля росли, не убывали
тренировка B   18 копий   1 содержимое             маршрут с первой копии
stateOfMind    26 и 26 копий, по 1 содержимому каждая

Что именно менялось у тренировки A между версиями:

версия 0 → 1   +stepCadence, +stepCount, изменилось значение activeEnergy
версия 1 → 2   набор полей тот же, изменились totalEnergy и basalEnergy

То есть тренировка досчитывается задним числом ровно так же, как минутное ведро (находка 10), и при этом набор полей за весь корпус ни разу не уменьшился.

Goals / Non-Goals

Goals:

  • Тренировка лежит в витрине целиком, вместе с маршрутом и внутренними рядами, дословно и без интерпретации.
  • Состояние разума лежит записями — секция, которой нет в экспорте Apple, больше не зависит от того, что тело не удалили.
  • Свёртка остаётся детерминированной: reindex даёт то же состояние, что живой приём, и это проверяется отпечатком, а не «числом строк».
  • Правило «при столкновении выигрывает более полная версия» продолжает действовать и для сущности с собственным id.

Non-Goals:

  • Отдача наружу. Read API в проекте нет вовсе; форма конверта, выбор слоя и предел размера ответа проектируются задачей read-api-tochki. Два эндпоинта, введённые раньше конверта, задали бы контракт мимоходом.
  • Секции, которых поток не приносил (ecg, symptoms, cycleTracking, medications, heartRateNotifications). Модель под них закладывается — таблица record ключуется родом секции, — но разбор не пишется вслепую: их формы никто не видел, а задача proverka-novyh-sekcij существует ровно про момент, когда они появятся.
  • Разворачивание маршрута в таблицу точек — отдельная идея беклога, у неё нет клиента.
  • Словарь категориальных значений (name тренировки — «В помещении Ходьба», машинная калька) — отдельная задача; здесь строка хранится дословно.

Decisions

1. Две таблицы, а не одна с колонкой рода

workout и record разведены, как и записано в docs/architecture.md.

Общая таблица entity(kind, id, …) выглядит экономнее и хуже по существу: у тренировки есть заголовок, который нужен запросом «что было за период» — name, start, end, duration, — а у записи состояния разума его нет. Общая таблица либо теряет заголовок (тогда список тренировок требует разжатия каждого блоба), либо заводит колонки, пустые у пяти родов из шести.

Отвергнуто и обратное — таблица на каждый род секции: шесть почти одинаковых таблиц, и каждая новая секция требует миграции. record ключуется родом, и новая секция добавляется одной строкой в множество покрытых имён.

2. Ключ record(kind, id), а не один id

Отклонение от схемы, набросанной в architecture.md (record(id PK, kind, …)), и оно намеренное. У stateOfMind id — настоящий UUID HealthKit, но остальные пять секций живьём не видели никто: форма их идентификатора неизвестна, и короткий несквозной id в двух разных секциях молча затёр бы одну запись другой. Пара стоит ноль (запросы к record всегда идут с родом: GET /records/{kind}) и снимает целый класс.

Ключ workoutid: род у него один.

3. Сущность заменяется целиком; побеждает не последняя, а не теряющая полей

Центральное решение задачи. Тренировка «перезаписывается» (беклог, architecture.md), но инвариант проекта гласит «при столкновении выигрывает более полная точка, а не последняя пришедшая». Развилку решает замер выше.

Правило:

1. каноническая форма совпала с сохранённой   → записи нет (хеш-детектор)
2. приехавшая несёт всё, что сохранённая, и
   сверх того                                 → приехавшая замещает целиком
3. приехавшая теряет содержание сохранённой   → остаётся сохранённая,
                                                 счётчик + WARN
4. содержание сравнимо (наборы равны)         → версия из БОЛЕЕ ПОЗДНЕЙ
                                                 доставки журнала
5. наборы несравнимы                          → остаётся сохранённая,
                                                 счётчик + WARN

«Теряет содержание» считается по множеству ключей, а не по canon.Relate. Это единственная деталь, где реализация не может переиспользовать правило точек как есть, и цена ошибки здесь — маршрут. Проверено выполненной командой на копии пакета canon:

сохранённая vs обеднённая, значения общих полей те же : superset
сохранённая vs обеднённая, значения общих полей иные  : equal
сохранённая vs усечённый маршрут (2 точки → 1)        : equal

canon.Fields.Relate гасит отношение включения до equal, когда значения общих содержательных ключей разошлись, — и это верно для точки (надмножество имён при других значениях означает другое измерение), но неверно для сущности: замер выше говорит, что между версиями тренировки значения меняются всегда. То есть настоящая обеднённая версия пришла бы с изменёнными значениями, получила бы equal и заместила бы сохранённую целиком, а тест на наивной фикстуре (значения не тронуты) остался бы зелёным.

Поэтому в canon заводится вторая, явная операция — сравнение множеств содержательных ключей без условия о совпадении значений, поверх уже существующего внутреннего relateKeys. Именно в canon, а не в store: пакет заведён ради единственной реализации сравнения, и вторая копия разошлась бы с первой молча.

Полнота меряется верхним уровнем ключей и длиной верхнеуровневых массивов. Второе добавлено намеренно: усечённый маршрут (3 точки вместо 593) ключа не теряет, поэтому одних множеств мало, а маршрут — 95% веса тренировки. Досчёт ряды удлиняет, а не укорачивает, так что укорачивание — законный сигнал «приехало меньше». Предел правила назван вслух: сокращение внутри элемента ряда (точка маршрута потеряла altitude) не ловится ничем.

Тай-брейк при равных наборах — позиция доставки в журнале, а не порядок свёртки. У точки при равной полноте исход решает порядок канонических форм (canon.Less) — тай-брейк, который намеренно не выбран, пока не измерен род агрегации. Приложи его к тренировке — и на наших же данных версия 1 → 2 (набор полей тот же, досчитаны totalEnergy и basalEnergy) осталась бы на произвольной из двух навсегда: тренировка замерла бы с недосчитанной энергией. Причина расхождения содержательная: у точки на одной координате законно встречаются два разных измерения (разные устройства, пересэмплирование), и предпочитать позднее нет оснований; у сущности id — идентичность одного объекта HealthKit, и вторая версия есть тот же объект, пересчитанный источником.

Отвергнуто и напрашивавшееся «побеждает приехавшая»: приехавшая — это функция порядка свёртки, а он не равен порядку журнала. Спека приёма говорит прямо, что воркер сворачивает в порядке (received_at, id) только среди видимых ему доставок, а абсолютного порядка при конкурентных приёмах не обещает (docs/architecture.md, «Предел порядка назван вслух»; открытый блокер poryadok-zhurnala-na-priyome.md). Доставка с более ранней меткой, свёрнутая позже, вернула бы витрину к недосчитанной версии — и reindex разошёлся бы с живым приёмом молча, в содержимом тренировки. Поэтому сущность несёт провенанс — delivery_id и received_at своей доставки, — а тай-брейк сравнивает пару (received_at, id). Тогда исход при равных наборах зависит только от журнала, а не от того, кто раньше добрался до базы.

Провенанс нужен и сам по себе: у часового объекта он обязателен («провенанс для разбора слияний»), а WARN об удержанной обеднённой версии без него не связать с телом в архиве.

Две версии с одинаковым ключом внутри одной доставки (позиции равны) разрешаются минимумом канонической формы: порядок элементов в JSON-массиве нестабилен, и опираться на него нельзя.

Почему не голый upsert по id (как делает сервер HealthyApps поверх MongoDB и как просилось из формулировки «перезаписывается»): единственный сценарий, ради которого тренировка приезжает повторно, — доезжающий маршрут, то есть рост. Обратное — приезд версии без маршрута — за 44 доставленные копии не случилось ни разу, но стоит 95% содержимого тренировки, а восстановление требует пересборки всего журнала. Условие пункта 3 стоит одного сравнения множеств и делает событие наблюдаемым вместо необратимого.

Несравнимые наборы (приехавшая принесла новые ключи и потеряла старые) в пункте 5 разрешаются в пользу сохранённой: поля не объединяются, объединение отвергнуто там же, где для точек, — на живом потоке событие не наступало ни разу, и вместо реализации заведено наблюдение.

Остаточный предел назван вслух. Слияние попарное — сохранённая против приехавшей, — поэтому при несравнимых наборах (пункт 5) исход зависит от порядка проигрывания. Тот же предел есть у часового объекта: в объекте лежит победитель прошлых слияний, а не все кандидаты истории. Пункты 2–4 от порядка свёртки не зависят, а пункт 5 сопровождается счётчиком и WARN, поэтому событие не будет молчаливым.

4. Свёртка доставки остаётся одной транзакцией

MergePoints превращается в Merge(ctx, Incoming{Points, Workouts, Records}, deliveryID): точки, тренировки и записи одной доставки пишутся одной транзакцией. Спека хранения требует этого прямо («Доставка SHALL сворачиваться одной транзакцией»), и требование не про точки, а про доставку: частичное состояние ломает инвариант «состояние пересобираемо».

Наблюдение «ни одна доставка не несла двух секций сразу» (находка 50) собрано за двое суток и основанием для второй транзакции не является.

Имя MergePoints уходит: метод перестал сливать одни точки, а два метода с двумя транзакциями были бы вторым способом делать то же самое.

5. payload — сжатый блоб, как у часового объекта

Дословные байты сущности, gzip. Тот же приём и по той же причине, что у bucket: маршрут — 95% веса тренировки, JSON такого рода жмётся примерно в 25 раз, а прогулка в час даёт порядка мегабайта. Цена названа там же и здесь та же: внутрь payload не заглянуть SQL-функциями. Для хранилища, которое отдаёт тренировку целиком, это не потеря; заголовок, по которому идёт выборка, лежит колонками.

Отвергнуто хранение текстом (как было набросано в architecture.md, payload JSON): второе кодирование для той же по природе величины стоило бы дороже любой выгоды от json_extract, а объём — сотни мегабайт в год против десятков.

6. Заголовок тренировки — ровно то, по чему идёт выборка

name, start_utc, end_utc, tz_offset, duration_sec. Больше ничего: любая следующая колонка — это решение за Apple о том, что в тренировке главное (находка 15: сводки дублируют ряды, distance — это сумма walkingAndRunningDistance).

duration берётся из тела, а не считается как end - start: HAE шлёт 91.746 секунды при интервале в 91 секунду, и вычисленное значение молча разошлось бы с присланным. Отсутствует или не число — колонка NULL, а не ноль: ноль — законная длительность, и потребитель, просуммировавший столбец, не отличил бы «источник не прислал» от «измерено ноль». Тело в payload дословно в любом случае.

end нечитаем или отсутствует — end_utc равен start_utc. У точки вырождение интервала в мгновение запрещено, потому что схлопывает координату; у сущности ключ — id, схлопывать нечего, а истина остаётся в payload. Офсет берётся из start: колонка одна, а пробежка через смену зоны дала бы два разных.

Метка записи — start, при его отсутствии date. end в заголовок не идёт: у рода daily_mood он может отстоять от начала на сутки, и вторая колонка понадобится вместе с запросом, которого пока нет.

name локализован («В помещении Ходьба»); хранится дословно, стабильный код припишет задача словаря категориальных значений.

Значение kind у записи — верхнеуровневый ключ секции HAE дословно (stateOfMind, не state_of_mind): инвариант «форма Apple не транслируется» относится и к именам секций, а переименование после мерджа стоило бы миграции данных.

7. Метка времени: у сущности оба известных формата, у точки — один

parseEntityTime пробует формат HAE (2026-07-31 21:03:51 +0300), затем RFC 3339 (2026-07-31T18:03:51Z). Форматы измерены (находка 16: у тренировок первый, у stateOfMind второй) и не пересекаются.

Отвергнуто приписывание формата секции: оно точнее описывает сегодняшний день и ломается молча в тот, когда HAE выровняет секции между собой — а он к этому идёт (stateOfMind уже шлёт честные коды HealthKit там, где старые секции шлют переводы, находка 37). Цена терпимости нулевая: неоднозначности между двумя формами нет.

Точка остаётся строгой, и это не забывчивость. У точки по метке выводится слой, причём по метке местной: метка в UTC объявила бы часовую выгрузку минутной, и минутный слой сложился бы с часовым (находка 35 — ровно такое удвоение уже наблюдалось). Терпимый парсер там означал бы тихую порчу разреза; строгий отдаёт непонятую метку в счётчик пропусков и WARN, а тело остаётся в архиве. У сущности слоя нет, и терять на строгости нечего — асимметрия намеренная.

Следствие, которое надо назвать вслух: у stateOfMind tz_offset всегда 0, потому что HAE прислал UTC, а не потому, что человек был в Гринвиче. Местная зона этой секции в потоке отсутствует.

8. Отпечаток витрины покрывает сущности, и снимается одним снимком

Store.Fingerprint — единственный оракул сходимости reindex и task verify:archive. Оставить его отпечатком одних часовых объектов значило бы получить «состояние сошлось» при разъехавшихся тренировках — то есть сломать проверку молча, ровно тем изменением, которое добавляет данные.

Три раздела читаются одной read-only транзакцией. Сегодня отпечаток — один SELECT, то есть один снимок; три запроса подряд вне транзакции в режиме WAL дают три снимка, а рабочий отпечаток снимается под живым приёмом. Свёртка, закоммитившаяся между запросами, дала бы смесь «объекты до» и «тренировки после», то есть ложное «разошлись» у единственного оракула. Прецедент в проекте есть — Store.Bucket уже читает в BeginTx(ReadOnly).

Строки разделов идут с константным тегом впереди (b|, w|, r|): без него строка одного раздела может совпасть со строкой другого — та же причина, по которой поля переменной длины уже идут с длиной впереди.

Отчёт reindex расширяется вместе с отпечатком: счётчики тренировок и записей «было и стало» рядом с числом объектов, и «покрыта новая секция» в перечне ожидаемых классов расхождения. Иначе первый же прогон после мерджа даст гарантированное расхождение отпечатков при неизменившемся числе объектов — и оракул выродится в шум ровно тогда, когда по нему принимается необратимое решение о подмене базы.

9. Покрытыми становятся ровно две секции

covered() — множество из трёх имён: metrics, workouts, stateOfMind. Прочие секции с собственным id остаются в списке непокрытых, доставка с ними остаётся partial, тело — в архиве. Это честно: формы этих секций никто не видел, а «полнота покрытия HealthKit ради полноты» целью проекта не является (паспорт).

Следствие, которое надо назвать вслух и передать дальше: доставка из одного stateOfMind теперь получает parsed с пустым списком непокрытых, то есть становится неотличимой от доставки из метрик — а метрики восстановимы из экспорта Apple, состояние разума нет (находка 46). До этой задачи защита работала побочным эффектом непокрытости. Ретеншена в проекте нет, поэтому здесь ничего не ломается сегодня; но предусловие, которое задача retenshen-syrogo-arhiva считала снятым, снова открыто, и это записывается в её файл тем же изменением.

9а. Отказ разбора остаётся «всё или ничего» — теперь и для сущностей

Действующее требование сформулировано через точки, потому что другого результата у разбора не было. Три ветки надо назвать явно, иначе каждая решается реализацией молча:

  • Тело оборвано после уже разобранной секции. Разбор отдаёт ошибку и ни точек, ни сущностей: иначе часть данных легла бы в витрину под статусом, по которому доставку никто не подберёт.
  • Слой метрик не выводится, а в теле есть сущности. Доставка целиком уходит в failed, сущности не пишутся. Соблазн «сущностям слой не нужен, запишем их» ломает то же «всё или ничего»: доставка получила бы failed при частично записанной витрине, и повторная свёртка перестала бы быть no-op. Тело остаётся в архиве, доставку вернёт пересборка. Цена названа: если такая доставка когда-нибудь принесёт stateOfMind, его записи доедут не сразу, а ретеншен failed-тела трогать не вправе.
  • Повтор ключа покрытой секции в одном data. Секции объединяются, как уже задано для metrics. Заодно чинится существующий дефект уровнем выше: decodeEnvelope при повторе самого члена data результат второго члена присваивает, а не добавляет, и имена первого глушатся общим seen — тело с двумя data доезжает до parsed с молча потерянной секцией.

9б. Пределы на чужие строки

id приходит из тела и ничем не ограничен, а уезжает и в первичный ключ, и в записи лога. Предел — 128 байт (UUID HealthKit — 36); сущность с более длинным id пропускается тем же счётчиком, что и сущность без id. Правило то же, что уже действует для имён непокрытых секций, и оно снимает класс, а не случай.

10. Миграция пересворачивает то, что стало покрытым

Спека хранения уже требует: «Задача, которая начинает разбирать секцию, тем же изменением SHALL переводить partial-строки с этим ключом в pending». Миграция 00007 переводит в pending доставки, у которых в uncovered_sections встречается workouts или stateOfMind. Дальше их подберёт обычный проход фонового воркера — отдельного кода для этого не существует.

Перевод точечный, а не «все partial»: список — снимок покрытия, и доставка с непокрытой ecg пересворачивать нечего.

Risks / Trade-offs

  • Приехала версия без маршрута, а поля при этом переименовались → правило пункта 5 удержит сохранённую версию навсегда, и новых полей витрина не увидит. → Счётчик и WARN с id сущности; тело в архиве, reindex применит исправленное правило. Событие не наблюдалось ни разу.
  • Сокращение внутри элемента ряда правилом не ловится. Длина верхнеуровневых массивов сравнивается, а точка маршрута, потерявшая altitude, — нет. → Названо вслух; ловится только сверкой с телом в архиве.
  • Маршрут удлиняет транзакцию свёртки. Верхняя граница задаётся не примером, а пределом тела приёма: 64 МиБ распакованного тела из одних тренировок дают десятки мегабайт содержимого в одной транзакции, а задача беклога «Цена слияния на широкой доставке» уже описывает, как 63 МБ на одной координате держат транзакцию дольше busy_timeout. → Хеш-детектор снимает 41 запись из 44 на нашем корпусе, а сравнение начинается с узкого SELECT content_hash, без чтения и разжатия блоба. Отдельной задачи не заводим: случай выражается той же беклоговой задачей, что и точки.
  • Канонизация маршрута разворачивает его в дерево any. canon.Form материализует значение целиком — та самая форма, от которой отказался разбор тела (197 МиБ кучи против 54 МиБ на теле 42 МиБ). → Хеш приехавшей сущности считается один раз на доставку, до входа в транзакцию, а не на каждой из пяти попыток повтора при занятости базы.
  • import родного экспорта Apple не даст id тренировки. В export.xml элемент Workout идентификатора не несёт — dogsheep/healthkit-to-sqlite поэтому адресует тренировку хешем содержимого (hash_id="id" в sqlite-utils). Значит импорт снапшота задвоит тренировки, приехавшие от HAE, — ровно та же дыра, что у точек, где её закрыли ключом start + end. → Предел назван здесь и заводится задачей беклога; сегодня импорта нет, и решать это до его формы значило бы угадывать.
  • tz_offset у записей состояния разума всегда ноль — не потеря наша, а форма источника. → Названо в docs/database.md, чтобы клиент не считал по нему местные сутки.
  • Фикстуры собираются из живого архива. Тренировка несёт координаты маршрута, запись состояния разума — эмоциональные метки; и то и другое чувствительнее токенов. → Скрипт tmp/research/fixtures.py расширяется: вычищаются числа (включая широту и долготу), UUID, метки RFC 3339 и словарные значения stateOfMind; сохраняются форма литерала, структура и порядок ключей. Проверка «в индексе нет данных о здоровье» остаётся за гейтом.

Migration Plan

Миграция 00007_workout_record.sql:

  1. CREATE TABLE workout и CREATE TABLE record с индексами по времени.
  2. UPDATE delivery SET parse_status = 'pending' для строк, чей uncovered_sections содержит workouts или stateOfMind.

Откат (Down) снимает таблицы; восстановление содержимого — обычная пересборка из архива, витрина производна по построению. Строки, переведённые в pending, Down обратно не возвращает: какими они были, восстановить неоткуда, а pending консервативен — ретеншен его не трогает.

Живой сервис миграцию переживает: новые таблицы никого не блокируют, UPDATE идёт по 118 строкам.