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
+117 -14
View File
@@ -317,9 +317,11 @@ HRV); у накопительных — только `date`. Поэтому то
#### Частичный разбор
Разбор покрывает секцию `metrics`; `workouts`, `stateOfMind`, `symptoms`, `ecg`
и прочие проходят мимо. Это половина потока: 48 доставок из 99 не несут
`metrics` вовсе (находка 50).
Разбор покрывает `metrics`, `workouts` и `stateOfMind`; `symptoms`, `ecg`,
`cycleTracking`, `medications` и `heartRateNotifications` проходят мимо. Живой
поток последних не приносил ни разу (118 доставок: 65 с метриками, 27 с
тренировками, 26 с состоянием разума), так что сегодня непокрытая секция —
редкость, а не половина потока, как было до покрытия сущностей.
Такая доставка получает статус `partial`, а имена непокрытых секций — колонку
`delivery.uncovered_sections`. Статус отвечает на вопрос «разобрано ли всё»,
@@ -343,7 +345,15 @@ HRV); у накопительных — только `date`. Поэтому то
покрытия на момент свёртки; доставки, свёрнутые до того, как секция стала
покрытой, останутся `partial` со старым списком, и ретеншен будет вечно щадить
ненужные тела. Задача, которая начинает разбирать секцию, тем же изменением
переводит `partial`-строки с этим ключом в `pending`.
переводит `partial`-строки с этим ключом в `pending`. Так сделала миграция
`00007`, покрывшая `workouts` и `stateOfMind`.
**Следствие для ретеншена, названное вслух.** Пока `stateOfMind` был непокрыт,
его тела защищал сам статус `partial`. Теперь такая доставка получает `parsed` и
неотличима от доставки из метрик — а метрики восстановимы из экспорта Apple,
состояние разума нет (находка 46). Ретеншена в проекте нет, поэтому сегодня не
ломается ничего; но предусловие, которое задача ретеншена считала снятым, снова
открыто, и признак невосстановимости придётся завести отдельно от «непокрытости».
- **413** — тело больше допустимого. Граница стоит на **распакованном**
потоке, а не только на сжатом: `MaxBytesReader` поверх `r.Body` ограничивает
@@ -551,12 +561,14 @@ bucket(metric, layer, hour_utc, units, payload BLOB, content_hash, points,
first_ts, last_ts, first_delivery_id, sealed, created_at, updated_at)
PK (metric, layer, hour_utc) WITHOUT ROWID
workout(id PK, name, start_utc, end_utc, tz_offset, duration_sec,
payload JSON, delivery_id, updated_at)
workout(id PK, name, start_utc, end_utc, tz_offset, duration_sec REAL NULL,
payload BLOB, content_hash, delivery_id, delivery_received_at,
created_at, updated_at)
INDEX (start_utc)
record(id PK, kind, ts_utc, tz_offset, payload JSON,
delivery_id, updated_at)
INDEX (kind, ts_utc)
record(kind, id, ts_utc, tz_offset, payload BLOB, content_hash,
delivery_id, delivery_received_at, created_at, updated_at)
PK (kind, id) INDEX (kind, ts_utc)
```
Зачем пачками:
@@ -814,10 +826,17 @@ value_code "HKCategoryValueSleepAnalysisAsleepREM" ← выведено по с
### Тренировки и прочие секции
Тренировка адресуется своим `id` из HealthKit и **перезаписывается**: она
может приехать повторно, когда доедет маршрут. `record` держит секции с
собственными идентификаторами (`stateOfMind`, `ecg`, `symptoms`,
`cycleTracking`, `medications`, `heartRateNotifications`) — модель та же.
Тренировка адресуется своим `id` из HealthKit, запись — парой `род + id`.
`record` держит секции с собственными идентификаторами; разбором покрыт пока
только `stateOfMind`, а `ecg`, `symptoms`, `cycleTracking`, `medications` и
`heartRateNotifications` остаются непокрытыми **намеренно**: живой поток не
приносил их ни разу, их формы никто не видел, а полнота покрытия HealthKit ради
полноты целью проекта не является. Модель под них заложена — новая секция
добавляется одной строкой в множество покрытых имён, а не миграцией.
Ключ записи — **пара**, а не один `id`: собственный `id` наблюдался живьём
только у `stateOfMind`, где он UUID, и короткий несквозной идентификатор в двух
разных секциях затёр бы одну запись другой молча.
Пачками они не хранятся: у них есть естественный ключ, они редки, и
группировать их по часам незачем.
@@ -825,12 +844,96 @@ value_code "HKCategoryValueSleepAnalysisAsleepREM" ← выведено по с
**Тренировка не разворачивается.** Заголовок — колонками, всё остальное,
включая маршрут и внутренние ряды, — блобом `payload`. Структура тренировки
разнородна и избыточна (сводки дублируют ряды, находка 15); раскладывать её в
таблицы значило бы решить за Apple, что в ней главное.
таблицы значило бы решить за Apple, что в ней главное. Колонок ровно столько,
сколько нужно выборке: имя, интервал, офсет зоны, длительность. Длительность
берётся из тела, а не считается как `end - start` (HAE шлёт 91.746 при
интервале в 91 секунду), и её отсутствие выражается пустотой, а не нулём — ноль
законная длительность.
**Пульс приедет дважды** — в общем потоке метрики `heart_rate` и внутри
объекта тренировки. Это ожидаемо, они лежат в разных таблицах и не
смешиваются.
#### Замена версии сущности
«Перезаписывается» уточнено измерением. Тренировка приезжает повторно каждой
доставкой, пока источник её досчитывает: на живом архиве одна тренировка
приехала 26 раз в трёх различных содержимых — сперва добавились `stepCadence` и
`stepCount` вместе с изменившимся рядом `activeEnergy`, затем при том же наборе
полей досчитались `totalEnergy` и `basalEnergy`. То есть тренировка правится
задним числом ровно так же, как минутное ведро (находка 10), а набор её полей
за весь корпус ни разу не уменьшился.
Правило:
```
1. каноническая форма совпала с сохранённой → записи нет (хеш-детектор)
2. приехавшая несёт всё содержание сохранённой
и сверх того → приехавшая замещает целиком
3. приехавшая теряет содержание сохранённой → остаётся сохранённая,
счётчик + WARN
4. содержание равно → версия из более поздней
доставки ЖУРНАЛА
5. наборы несравнимы → остаётся сохранённая,
счётчик + WARN
```
**Содержание сравнивается множеством ключей с непустым значением и длиной
верхнеуровневых массивов — но не значениями.** Правило полноты, принятое для
точек, здесь неприменимо, и это проверено выполненной командой: оно гасит
отношение включения до «равенства», когда значения общих ключей разошлись, — а
у сущности они расходятся всегда. Обеднённая версия получила бы «равенство» и
заместила бы сохранённую вместе с маршрутом, причём тест на фикстуре с
неизменёнными значениями остался бы зелёным. Длина массивов добавлена потому,
что усечённый маршрут (три точки вместо 593) ключа не теряет, а теряет 95% веса
тренировки. Предел правила назван вслух: сокращение **внутри** элемента ряда не
ловится ничем, кроме сверки с телом в архиве.
**Тай-брейк при равном содержании — позиция доставки в журнале
`(received_at, id)`, а не порядок свёртки.** Напрашивавшееся «побеждает
приехавшая» отвергнуто: приехавшая есть функция порядка свёртки, а он порядку
журнала не равен (см. «Предел порядка назван вслух»). Доставка с более ранней
меткой, свёрнутая позже, вернула бы витрину к недосчитанной версии, и
пересборка разошлась бы с живым приёмом **молча** — в содержимом тренировки, где
это не видно ничем, кроме отпечатка. Поэтому сущность несёт провенанс:
доставку своей версии и её метку приёма. Тай-брейк по канонической форме (как у
точек) отвергнут по другой причине: он заморозил бы тренировку на произвольной
из версий навсегда, вместе с недосчитанной энергией.
Две версии одного ключа **внутри одной доставки** позициями не различаются и
разрешаются минимумом канонической формы: порядок элементов в JSON-массиве
нестабилен.
Отвергнут и **голый upsert по `id`** (так делает сервер HealthyApps поверх
MongoDB, и так просилось из слова «перезаписывается»): единственный наблюдённый
сценарий повторной присылки — рост, но маршрут стоит 95% содержимого, а
восстановление требует пересборки всего журнала. Условие пункта 3 стоит одного
сравнения множеств и делает событие наблюдаемым вместо необратимого.
Остаточный предел назван вслух: слияние попарное, поэтому при несравнимых
наборах (пункт 5) исход зависит от порядка проигрывания. Тот же предел есть у
часового объекта — в нём лежит победитель прошлых слияний, а не все кандидаты
истории.
#### Отпечаток и отчёт пересборки идут за витриной
Отпечаток покрывает **все** единицы хранения и снимается одной транзакцией
чтения: отпечаток одних часовых объектов давал бы «состояние сошлось» при
разъехавшихся тренировках, а три запроса вне общей транзакции под живым приёмом
дали бы смесь снимков и ложное «разошлись». Отчёт `reindex` считает «было и
стало» по каждой единице и называет «покрыта новая секция» ожидаемым классом
расхождения — иначе первый прогон после такого изменения расходится
гарантированно, а человек читает это как дефект.
#### Предел, который придётся закрыть импортом
В `export.xml` у элемента `Workout` идентификатора нет вовсе —
`dogsheep/healthkit-to-sqlite` поэтому адресует тренировку **хешем содержимого**
(`hash_id` в sqlite-utils). Значит `import` снапшота задвоит тренировки,
приехавшие от HAE: та же дыра, что у точек, где её закрыли ключом
`start + end`. Сегодня импорта нет, и решать это до его формы значило бы
угадывать; предел записан в беклоге отдельной задачей.
### Время
Точка внутри объекта хранится дословно, вместе с исходной строкой даты. Для