feat: разбор и хранение тренировок и состояния разума
- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`; миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки с этими ключами - сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА — «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина расходилась бы с пересборкой молча - отпечаток витрины покрывает тренировки и записи и снимается одним снимком базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
This commit is contained in:
+117
-14
@@ -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`. Сегодня импорта нет, и решать это до его формы значило бы
|
||||
угадывать; предел записан в беклоге отдельной задачей.
|
||||
|
||||
### Время
|
||||
|
||||
Точка внутри объекта хранится дословно, вместе с исходной строкой даты. Для
|
||||
|
||||
Reference in New Issue
Block a user