feat: разбор и хранение тренировок и состояния разума

- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной
  строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`;
  миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки
  с этими ключами
- сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет
  содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а
  при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА —
  «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина
  расходилась бы с пересборкой молча
- отпечаток витрины покрывает тренировки и записи и снимается одним снимком
  базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
This commit is contained in:
av
2026-08-02 13:05:16 +03:00
parent c28de9796e
commit f8200f7f80
47 changed files with 5817 additions and 301 deletions
@@ -0,0 +1,415 @@
## 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 строкам.