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`. Сегодня импорта нет, и решать это до его формы значило бы
|
||||
угадывать; предел записан в беклоге отдельной задачей.
|
||||
|
||||
### Время
|
||||
|
||||
Точка внутри объекта хранится дословно, вместе с исходной строкой даты. Для
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
- [Порядок журнала при конкурентных приёмах](poryadok-zhurnala-na-priyome.md) — доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда — живое состояние расходится с reindex
|
||||
|
||||
## высокий
|
||||
- [Тренировки и секции с собственными id](trenirovki-i-zapisi.md) — Тренировки с геотреком и состояние разума приходят, но не разбираются — без них не закрыть ни трекер, ни агента-медика
|
||||
- [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое
|
||||
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
|
||||
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
|
||||
@@ -31,6 +30,7 @@
|
||||
- [Словарь категориальных значений → коды HealthKit](slovar-kategorialnyh-znachenij.md) — Фазы сна и типы тренировок приходят строками русской локали — с экспортом Apple их не сверить
|
||||
- [Выведенные из данных схемы содержимого](samoopisanie-shemy.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
|
||||
- [Импорт родного экспорта Apple Health](import-eksporta-apple.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
|
||||
- [Идентичность тренировок при импорте родного экспорта](identichnost-trenirovok-pri-importe.md) — В export.xml у тренировки нет id — импорт снапшота задвоит тренировки, приехавшие от HAE
|
||||
- [Проверка секций, которых поток ещё не приносил](proverka-novyh-sekcij.md) — Симптомы, ЭКГ, лекарства, цикл и вес не виденны живьём — разбор писался вслепую
|
||||
- [Наблюдаемость: /stats](stats-nablyudaemost.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
|
||||
- [Проверка целостности собранной витрины перед подменой](celostnost-pered-podmenoj.md) — Подмена файла необратима, а годность выхода подтверждена отпечатком на ещё открытом дескрипторе
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Идентичность тренировок при импорте родного экспорта
|
||||
|
||||
**Приоритет:** средний
|
||||
|
||||
Тренировка в витрине адресуется своим `id` из HealthKit — его шлёт HAE. В
|
||||
`export.xml` этого идентификатора **нет вовсе**: у элемента `Workout` только
|
||||
тип, источник, даты и статистика. Значит `healthlog import` не сможет сопоставить
|
||||
тренировку из снапшота с той же тренировкой, уже приехавшей от HAE, и они
|
||||
задвоятся.
|
||||
|
||||
Это ровно та дыра, что была у точек, и там её закрыли ключом `start + end`
|
||||
(находка 47): `HKObject.uuid` в выгрузку не попадает, поэтому модель
|
||||
идентичности обязана выражаться через интервал.
|
||||
|
||||
Prior art: `dogsheep/healthkit-to-sqlite` адресует тренировку **хешем
|
||||
содержимого** (`hash_id` в sqlite-utils) — ровно потому, что идентификатора в
|
||||
экспорте нет. Нам это не подходит в лоб: у нас половина тренировок уже лежит под
|
||||
настоящим `id`, и хеш содержимого дал бы третий ключ рядом с двумя.
|
||||
|
||||
Варианты, которые надо будет сравнить:
|
||||
|
||||
- **Второй уникальный ключ `(start_utc, end_utc)`** у тренировки: импорт ищет по
|
||||
нему, HAE — по `id`. Цена: индекс и вопрос, что делать при столкновении двух
|
||||
разных тренировок с одним интервалом (бывает ли такое — неизвестно).
|
||||
- **Сопоставление на стадии импорта**, без изменения схемы: импорт читает уже
|
||||
сохранённые тренировки за период и приписывает найденным их `id`. Цена: логика
|
||||
сопоставления живёт в импорте и не проверяется ничем, кроме него.
|
||||
- **Считать тренировки из экспорта отдельным родом** и не сопоставлять вовсе.
|
||||
Цена: потребитель видит две тренировки вместо одной и обязан схлопывать сам —
|
||||
ровно то, чего проект старается не делать.
|
||||
|
||||
Решать до появления формы `healthlog import` значит угадывать: неизвестно,
|
||||
понадобятся ли тренировки из экспорта вообще (у HAE они полнее — с маршрутом и
|
||||
рядами, а в экспорте маршрут лежит отдельными GPX).
|
||||
|
||||
Связано: `docs/architecture.md` → «Тренировки и прочие секции», задача
|
||||
`import-eksporta-apple`.
|
||||
@@ -36,3 +36,10 @@
|
||||
секцию, которой раньше не было, теперь **фиксируется** — остаётся научиться
|
||||
замечать его активно: один `SELECT DISTINCT` по колонке даёт список всего, что
|
||||
поток приносил, и сравнение с известным набором закрывает задачу.
|
||||
|
||||
Модель под секции с собственным `id` заложена (change
|
||||
`2026-08-02-trenirovki-i-zapisi`): таблица `record` ключуется парой
|
||||
`род + id`, и новая секция добавляется **одной строкой** в множество покрытых
|
||||
имён разбора, а не миграцией. Покрыты `workouts` и `stateOfMind`; остались
|
||||
`ecg`, `symptoms`, `cycleTracking`, `medications`, `heartRateNotifications` —
|
||||
их формы никто не видел, и разбор вслепую сознательно не писался.
|
||||
|
||||
@@ -14,8 +14,17 @@
|
||||
и не влезает — ошибка со списком доступных сеток, а не тихая подмена. Различие
|
||||
существенно: иначе агент, попросивший минутную сетку, получит суточные суммы.
|
||||
|
||||
**Отдача тренировок и записей входит сюда же.** Разбор и хранение сущностей с
|
||||
собственным `id` сделаны (change `2026-08-02-trenirovki-i-zapisi`), а эндпоинтов
|
||||
нет: тренировка с маршрутом и записи `stateOfMind` лежат в витрине и наружу не
|
||||
отдаются. Вводить их раньше конверта ответа значило бы задать контракт
|
||||
мимоходом, поэтому `GET /workouts`, `GET /workouts/{id}` и
|
||||
`GET /records/{kind}` закрываются этой задачей — вместе с формой конверта и
|
||||
правилом размера ответа. Второй сценарий паспорта (трекер) до тех пор не закрыт.
|
||||
|
||||
Готово, когда «шаги за неделю по дням» и «вес за год» отвечаются одним запросом
|
||||
каждый, а в ответе всегда видно `layer`, `bucket` и `aggregation`.
|
||||
каждый, тренировка отдаётся одним пакетом вместе с маршрутом, а в ответе всегда
|
||||
видно `layer`, `bucket` и `aggregation`.
|
||||
|
||||
Связано: `docs/architecture.md` → «Read API», план → шаг «Read API».
|
||||
|
||||
|
||||
@@ -26,14 +26,38 @@
|
||||
экспорта, записи `stateOfMind` не трогаются вовсе, а `/stats` показывает
|
||||
глубину архива и дату снапшота, до которой он подрезан.
|
||||
|
||||
## Предусловие снято
|
||||
## Предусловие снова открыто
|
||||
|
||||
Признак, без которого ретеншен был опасен, готов: доставка с непокрытой секцией
|
||||
имеет статус `partial` и список непокрытых ключей
|
||||
(change `2026-08-01-nerazobrannye-sekcii-dostavki`). Ретеншен обязан спрашивать
|
||||
статус, а не считать `parsed` разрешением: тело `stateOfMind` восстановить
|
||||
неоткуда — в экспорте Apple секции нет.
|
||||
Признак «доставка с непокрытой секцией» появился в change
|
||||
`2026-08-01-nerazobrannye-sekcii-dostavki` и работал заодно защитой
|
||||
`stateOfMind`: такие доставки числились `partial`, и ретеншен их не тронул бы.
|
||||
|
||||
Change `2026-08-02-trenirovki-i-zapisi` покрыл `stateOfMind` разбором, и защита
|
||||
исчезла: доставка из одного состояния разума теперь получает `parsed` с пустым
|
||||
списком непокрытых, то есть **побайтово неотличима** от доставки из метрик — а
|
||||
метрики восстановимы из экспорта Apple, состояние разума нет (находка 46).
|
||||
Ретеншен, написанный по правилу «удаляем всё, что не `partial`», сотрёт ровно те
|
||||
тела, которых в экспорте не существует, и первая же пересборка потеряет историю
|
||||
состояния разума навсегда.
|
||||
|
||||
Значит признак невосстановимости нужен **не производный от «непокрытости»**.
|
||||
Варианты:
|
||||
|
||||
- **Перечень покрытых секций, которых нет в экспорте Apple** рядом с доставкой
|
||||
(сегодня — ровно `stateOfMind`). Цена: колонка и строка в свёртке; читается
|
||||
так же, как `uncovered_sections`, и одним запросом.
|
||||
- **Признак у доставки «тело — единственный источник»**, выставляемый разбором.
|
||||
Цена та же, но смысл шире и требует решения, что считать единственным
|
||||
источником для будущих секций.
|
||||
- **Никогда не подрезать тела доставок, у которых есть строки в `record`.**
|
||||
Цена нулевая по схеме, но неточная: провенанс записи указывает на доставку
|
||||
её **текущей** версии, а копий у записи бывает по 26.
|
||||
|
||||
Рекомендация — первый вариант: он прямо отвечает на вопрос «что останется
|
||||
потерянным», как это уже делает `uncovered_sections`, и не требует додумывать
|
||||
семантику.
|
||||
|
||||
Вместе с этим действует правило: задача, которая начинает разбирать секцию, тем
|
||||
же изменением переводит `partial`-строки с этим ключом в `pending`. Ретеншену
|
||||
позволено смотреть на `partial` только пока правило соблюдается.
|
||||
же изменением переводит `partial`-строки с этим ключом в `pending` (так сделала
|
||||
миграция `00007`). Ретеншену позволено смотреть на `partial` только пока правило
|
||||
соблюдается.
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Тренировки и секции с собственными id
|
||||
|
||||
**Приоритет:** высокий
|
||||
|
||||
Тренировки приезжают с геотреком, состояние разума — с кодами HealthKit. Ни то,
|
||||
ни другое сейчас не разбирается. Тренировки нужны трекеру (второй сценарий),
|
||||
состояние разума — агенту-медику.
|
||||
|
||||
Модель отличается от метрик: у этих сущностей есть собственный `id`, они редки,
|
||||
и по часам их группировать незачем. Тренировка **перезаписывается** целиком —
|
||||
она приезжает повторно, когда доедет маршрут.
|
||||
|
||||
Шаги:
|
||||
- миграции `workout` и `record` (секции `stateOfMind`, `ecg`, `symptoms`,
|
||||
`cycleTracking`, `medications`, `heartRateNotifications` — модель одна);
|
||||
- заголовок тренировки колонками, маршрут и внутренние ряды — блобом;
|
||||
- пульс внутри тренировки не смешивать с метрикой `heart_rate`: разные таблицы.
|
||||
|
||||
Готово, когда тренировка отдаётся одним пакетом вместе с маршрутом, а
|
||||
`stateOfMind` виден записями.
|
||||
|
||||
Связано: `docs/architecture.md` → «Тренировки и прочие секции».
|
||||
|
||||
+16
-2
@@ -89,8 +89,22 @@
|
||||
(`internal/ident`). Сортируется по времени создания, удобен в логах и URL.
|
||||
Разбор внешнего id — `ident.Parse` на входной границе; синтаксически
|
||||
невалидный id — 404 без похода в БД.
|
||||
- Естественный ключ вместо ULID там, где он есть по природе данных: `sample`
|
||||
и `record` — по хешу содержимого, `workout` — по `id` из HealthKit.
|
||||
- Естественный ключ вместо ULID там, где он есть по природе данных: `workout` —
|
||||
по `id` из HealthKit, `record` — по паре `род секции + id` (форму
|
||||
идентификатора у пяти из шести секций живьём никто не видел, и несквозной `id`
|
||||
в двух секциях затёр бы одну запись другой молча).
|
||||
- Новая единица хранения тем же изменением входит в **отпечаток витрины** и в
|
||||
счётчики отчёта пересборки. Отпечаток отвечает «да/нет» за витрину целиком, и
|
||||
единица, которой нет в счётчиках, делает расхождение безадресным: человек
|
||||
видит «не совпало» при неизменившемся числе объектов и принимает по этому
|
||||
необратимое решение о подмене базы.
|
||||
- Правило выбора между двумя версиями одних данных объявляется либо **функцией
|
||||
множества версий**, либо явно **функцией порядка журнала** — третьего
|
||||
состояния нет. «Побеждает последняя пришедшая» третьим состоянием и является:
|
||||
порядок свёртки порядку журнала не равен, и живая витрина расходится с
|
||||
пересборкой молча.
|
||||
- Любое значение из чужого JSON, попадающее в ключ, в лог или в отчёт, имеет
|
||||
названный предел длины (имена непокрытых секций, `id` сущности).
|
||||
- Временные метки — `TEXT` в RFC 3339, **UTC**, суффикс `Z`. Фиксированная
|
||||
ширина сохраняет лексикографическую сортировку = хронологию. Единая точка
|
||||
генерации — `store.Now()`, а не дефолт в схеме: забытая вставка должна
|
||||
|
||||
@@ -29,6 +29,22 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа
|
||||
│ derived_layer TEXT │ └──────────────────────────────┘
|
||||
│ uncovered_sections TEXT │
|
||||
└────────────────────────────┘
|
||||
┊ ┌──────────────────────────┐ ┌──────────────────────────┐
|
||||
┊ │ workout │ │ record │
|
||||
┊ доставка, │ ─────────────────────── │ │ ─────────────────────── │
|
||||
└┄┄┄ чья версия ┄┄┄┄▶ │ id TEXT PK│ │ kind TEXT ┐ │
|
||||
лежит сейчас │ name TEXT │ │ id TEXT ┘PK│
|
||||
│ start_utc TEXT │ │ ts_utc TEXT │
|
||||
│ end_utc TEXT │ │ tz_offset INTEGER│
|
||||
│ tz_offset INTEGER│ │ payload BLOB │
|
||||
│ duration_sec REAL? │ │ content_hash TEXT │
|
||||
│ payload BLOB │ │ delivery_id TEXT │
|
||||
│ content_hash TEXT │ │ delivery_received_at TEXT│
|
||||
│ delivery_id TEXT │ │ created_at TEXT │
|
||||
│ delivery_received_at TEXT│ │ updated_at TEXT │
|
||||
│ created_at TEXT │ └──────────────────────────┘
|
||||
│ updated_at TEXT │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
Связь `bucket.first_delivery_id → delivery.id` **внешним ключом не объявлена**
|
||||
@@ -89,3 +105,41 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа
|
||||
`метрика + слой + начало + конец`, у точки-измерения конец равен началу.
|
||||
`source` в ключ не входит: он нестабилен и переписывается задним числом. При
|
||||
столкновении выигрывает более полная точка, а не последняя пришедшая.
|
||||
|
||||
## `workout` и `record` — сущности с собственным `id`
|
||||
|
||||
Вторая единица хранения витрины. Часовой объект им не подходит: у них есть
|
||||
естественный ключ, они редки (за двое суток потока — две тренировки и две
|
||||
записи состояния разума при 44 и 52 доставленных копиях), и группировать их по
|
||||
часам незачем.
|
||||
|
||||
Таблицы две, а не одна с колонкой рода: у тренировки есть заголовок, по
|
||||
которому идёт выборка (имя, интервал, длительность), а у записи его нет. Общая
|
||||
таблица либо теряла бы заголовок, либо держала колонки, пустые у пяти родов из
|
||||
шести.
|
||||
|
||||
| Колонка | Смысл |
|
||||
|---|---|
|
||||
| `workout.id` | идентификатор из HealthKit. Приходит из тела и ограничен по длине разбором: уезжает и в ключ, и в записи лога |
|
||||
| `record.kind` + `record.id` | ключ — **пара**. Собственный `id` наблюдался живьём только у `stateOfMind`, где он UUID; форма идентификатора остальных пяти секций не наблюдалась никем, и короткий несквозной `id` в двух разных секциях затёр бы одну запись другой молча |
|
||||
| `kind` | верхнеуровневый ключ секции HAE **дословно** (`stateOfMind`, не `state_of_mind`): инвариант «форма Apple не транслируется» относится и к именам секций |
|
||||
| `start_utc` / `end_utc` / `ts_utc` | UTC RFC 3339. Конец, которого нет или который не читается, равен началу: ключ — `id`, схлопывать координаты нечем, а истина остаётся в `payload` |
|
||||
| `tz_offset` | смещение зоны **начала**. У `stateOfMind` всегда `0` — это значит «источник прислал UTC», а не «человек был в Гринвиче»: местной зоны у секции в потоке нет вовсе. Клиент, считающий по нему местные сутки, ошибётся |
|
||||
| `duration_sec` | длительность тренировки в секундах, как прислал HAE. **`NULL` означает «источник не прислал»**: ноль — законная длительность. Не вычисляется из интервала — HAE шлёт 91.746 при интервале в 91 секунду |
|
||||
| `payload` | сущность целиком исходными байтами, gzip: заголовок, маршрут, внутренние ряды и сводки. Маршрут — 95% веса тренировки, а такой JSON жмётся примерно в 25 раз. Внутрь SQL-функциями не заглянуть — та же плата, что у `bucket.payload` |
|
||||
| `content_hash` | хеш канонической формы: детектор изменений, не ключ. Тренировка переприсылается каждой доставкой, пока не доедет маршрут (44 копии дают три различных содержимых) |
|
||||
| `delivery_id`, `delivery_received_at` | провенанс: доставка, **чья версия лежит сейчас**, и её метка приёма. Не отчётность: по паре разрешается тай-брейк между версиями равной полноты |
|
||||
|
||||
Индексы: `workout_start_utc` («заголовки тренировок за период» — основной запрос
|
||||
трекера), `record_kind_ts` («записи такого-то рода за период» — единственная
|
||||
форма запроса к таблице).
|
||||
|
||||
**Ряд пульса внутри тренировки лежит в её `payload`, а не в объектах метрики
|
||||
`heart_rate`.** Пульс приезжает дважды — в общем потоке и внутри тренировки; это
|
||||
разные таблицы, и смешение задвоило бы ряд.
|
||||
|
||||
**Замена версии условна.** Приехавшая побеждает, если не теряет содержания
|
||||
сохранённой (множество ключей с непустым значением плюс длины верхнеуровневых
|
||||
массивов); при равных наборах выигрывает версия из более поздней доставки
|
||||
журнала, а не свёрнутая последней. Подробности и обоснование — в
|
||||
`architecture.md`, раздел «Тренировки и прочие секции».
|
||||
|
||||
@@ -1658,6 +1658,73 @@ apple_stand_time 14
|
||||
Отсюда статус `partial` и колонка `delivery.uncovered_sections`: статус
|
||||
отвечает на вопрос «разобрано ли всё», список — «что именно осталось».
|
||||
|
||||
## 51. Тренировка досчитывается задним числом, но поля у неё только прибывают
|
||||
|
||||
Замер по всем 118 доставкам архива, группировка элементов секций по `id`:
|
||||
|
||||
| сущность | копий | различных содержимых | набор полей рос | набор полей убывал |
|
||||
|---|---:|---:|---|---|
|
||||
| тренировка A | 26 | 3 | да | нет |
|
||||
| тренировка B | 18 | 1 | — | — |
|
||||
| `stateOfMind` #1 | 26 | 1 | — | — |
|
||||
| `stateOfMind` #2 | 26 | 1 | — | — |
|
||||
|
||||
Что менялось у тренировки A между версиями:
|
||||
|
||||
```
|
||||
версия 0 → 1 +stepCadence, +stepCount, изменилось значение ряда activeEnergy
|
||||
версия 1 → 2 набор полей тот же, изменились totalEnergy и basalEnergy
|
||||
```
|
||||
|
||||
Два вывода, и оба вошли в правило замены версии.
|
||||
|
||||
**Тренировка правится задним числом ровно так же, как минутное ведро**
|
||||
(находка 10): при неизменном наборе полей значения досчитываются. Значит
|
||||
правило «при равной полноте побеждает тот, чья каноническая форма меньше» —
|
||||
то, что действует для точек, — заморозило бы тренировку на произвольной версии
|
||||
навсегда, вместе с недосчитанной энергией.
|
||||
|
||||
**Набор полей за весь корпус ни разу не уменьшился.** Обеднённая версия —
|
||||
событие, которого поток не производит; но маршрут это 95% веса тренировки
|
||||
(находка 22), а восстановление требует пересборки всего журнала. Поэтому
|
||||
удержание сохранённой версии стоит одного сравнения множеств, а событие делается
|
||||
наблюдаемым — счётчиком и `WARN`, — вместо необратимого.
|
||||
|
||||
**Правило полноты, написанное для точек, здесь неприменимо.** Оно требует, чтобы
|
||||
значения общих содержательных ключей совпали, иначе отношение включения гасится
|
||||
до «равенства». У точки это верно (надмножество имён при других значениях
|
||||
означает другое измерение), у сущности — нет: значения между версиями
|
||||
расходятся всегда. Проверено на копии пакета `canon`:
|
||||
|
||||
```
|
||||
сохранённая с маршрутом vs обеднённая, значения общих полей те же : superset
|
||||
сохранённая с маршрутом vs обеднённая, значения общих полей иные : equal
|
||||
сохранённая vs версия с усечённым маршрутом (2 точки → 1) : equal
|
||||
```
|
||||
|
||||
Отсюда же второй разряд правила: усечённый ряд ключа не теряет, поэтому
|
||||
сравнивается ещё и длина верхнеуровневых массивов.
|
||||
|
||||
## 52. Половина потока — не `metrics`: перемер на 118 доставках
|
||||
|
||||
Пересчёт находки 50 на выросшем корпусе. Набор верхнеуровневых ключей `data`:
|
||||
|
||||
| набор ключей `data` | доставок |
|
||||
|---|---|
|
||||
| `metrics` | 65 |
|
||||
| `workouts` | 27 |
|
||||
| `stateOfMind` | 26 |
|
||||
|
||||
Пропорция та же, что была на 99 доставках (51/24/24), и наблюдение «ни одна
|
||||
доставка не несла двух секций сразу» держится: автоматизация HAE шлёт одну
|
||||
секцию за раз. Полагаться на это в правилах удаления данных по-прежнему нельзя —
|
||||
за двое суток наблюдения смешанная доставка просто не успела бы случиться.
|
||||
|
||||
С покрытием `workouts` и `stateOfMind` разбором эти 53 доставки перестали быть
|
||||
`partial`. Прогон живого архива после изменения: 118 тел, свёрнуто 118, отказов
|
||||
ноль, частично разобранных ноль, в витрине 2049 часовых объектов, 2 тренировки и
|
||||
2 записи; повторное проигрывание дало тот же отпечаток.
|
||||
|
||||
## Инструмент
|
||||
|
||||
Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная
|
||||
|
||||
Reference in New Issue
Block a user