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

416 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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}`) и снимает целый класс.
Ключ `workout``id`: род у него один.
### 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 строкам.