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
+11
View File
@@ -174,6 +174,11 @@ type report struct {
dbPath string dbPath string
sourcePrint string sourcePrint string
sourceBuckets int64 sourceBuckets int64
// sourceWorkouts и sourceRecords — то же «было» для остальных единиц
// хранения витрины. Отпечаток отвечает «да/нет» за витрину целиком, поэтому
// единица, которой нет в счётчиках, делает расхождение безадресным.
sourceWorkouts int64
sourceRecords int64
sourceBefore int64 sourceBefore int64
sourceAfter int64 sourceAfter int64
sourceMissing bool sourceMissing bool
@@ -226,6 +231,12 @@ func rebuild(ctx context.Context, cfg *config.Config, t target, log *slog.Logger
if rep.sourceBuckets, err = src.CountBuckets(ctx); err != nil { if rep.sourceBuckets, err = src.CountBuckets(ctx); err != nil {
return canceledOr(rep, err, stopped) return canceledOr(rep, err, stopped)
} }
if rep.sourceWorkouts, err = src.CountWorkouts(ctx); err != nil {
return canceledOr(rep, err, stopped)
}
if rep.sourceRecords, err = src.CountRecords(ctx); err != nil {
return canceledOr(rep, err, stopped)
}
} }
removeDB(t.partial) removeDB(t.partial)
+7 -2
View File
@@ -58,6 +58,8 @@ func writeReport(w io.Writer, r report) {
if r.sourceMissing { if r.sourceMissing {
p(" объектов: %d", r.replay.Buckets) p(" объектов: %d", r.replay.Buckets)
p(" тренировок: %d", r.replay.Workouts)
p(" записей: %d", r.replay.Records)
p("") p("")
p("рабочей базы %s нет — сверять не с чем, а заголовки доставок", r.dbPath) p("рабочей базы %s нет — сверять не с чем, а заголовки доставок", r.dbPath)
p("не восстанавливаются: в архиве их нет.") p("не восстанавливаются: в архиве их нет.")
@@ -66,6 +68,8 @@ func writeReport(w io.Writer, r report) {
// расхождения. Отпечатки отвечают «да/нет», а решение о подмене // расхождения. Отпечатки отвечают «да/нет», а решение о подмене
// необратимо; именно пара чисел 1737/1742 поймала прошлый дефект. // необратимо; именно пара чисел 1737/1742 поймала прошлый дефект.
p(" объектов: было %d, стало %d", r.sourceBuckets, r.replay.Buckets) p(" объектов: было %d, стало %d", r.sourceBuckets, r.replay.Buckets)
p(" тренировок: было %d, стало %d", r.sourceWorkouts, r.replay.Workouts)
p(" записей: было %d, стало %d", r.sourceRecords, r.replay.Records)
p("") p("")
p(" отпечаток рабочей: %s", r.sourcePrint) p(" отпечаток рабочей: %s", r.sourcePrint)
p(" отпечаток пересобранной: %s", r.replay.Fingerprint) p(" отпечаток пересобранной: %s", r.replay.Fingerprint)
@@ -76,8 +80,9 @@ func writeReport(w io.Writer, r report) {
p(" отпечатки совпали, но сверка НЕПОЛНА: часть журнала не прочитана") p(" отпечатки совпали, но сверка НЕПОЛНА: часть журнала не прочитана")
default: default:
p(" отпечатки РАЗОШЛИСЬ") p(" отпечатки РАЗОШЛИСЬ")
p(" ожидаемые причины: исправленный разбор; признак sealed не") p(" ожидаемые причины: исправленный разбор; покрытая разбором новая")
p(" переносится (правила его выставления ещё нет)") p(" секция (её единиц хранения в рабочей базе нет по построению);")
p(" признак sealed не переносится (правила его выставления ещё нет)")
if partialJournal { if partialJournal {
p(" ВНИМАНИЕ: часть журнала не прочитана — расхождение может") p(" ВНИМАНИЕ: часть журнала не прочитана — расхождение может")
p(" объясняться этим, а не разбором") p(" объясняться этим, а не разбором")
+14 -2
View File
@@ -88,12 +88,14 @@ func TestОтчётНеРаскрываетДанныхОЗдоровье(t *tes
replay: replay.Report{ replay: replay.Report{
Bodies: 116, Bodies: 116,
Outcome: replay.Outcome{Folded: 116, Partial: 53}, Outcome: replay.Outcome{Folded: 116, Partial: 53},
Buckets: 2049, Fingerprint: "aaaa", Buckets: 2049, Workouts: 2, Records: 2, Fingerprint: "aaaa",
}, },
target: "/data/healthlog.db.rebuild", target: "/data/healthlog.db.rebuild",
dbPath: "/data/healthlog.db", dbPath: "/data/healthlog.db",
sourcePrint: "bbbb", sourcePrint: "bbbb",
sourceBuckets: 2040, sourceBuckets: 2040,
sourceWorkouts: 0,
sourceRecords: 0,
sourceBefore: 116, sourceBefore: 116,
sourceAfter: 116, sourceAfter: 116,
}) })
@@ -113,7 +115,17 @@ func TestОтчётНеРаскрываетДанныхОЗдоровье(t *tes
// Расхождение отпечатков названо, и рядом — направление: «было/стало». // Расхождение отпечатков названо, и рядом — направление: «было/стало».
// Отпечатки отвечают «да/нет», а решать по ним человеку необратимое. // Отпечатки отвечают «да/нет», а решать по ним человеку необратимое.
for _, want := range []string{"РАЗОШЛИСЬ", "было 2040, стало 2049", "task down", "mv "} { //
// «Было/стало» обязано покрывать КАЖДУЮ единицу хранения: единица, которой
// нет в счётчиках, делает расхождение безадресным — человек видит «не
// совпало» при неизменившемся числе объектов. Класс «покрыта новая секция»
// назван отдельно потому, что первый прогон после такого изменения
// расходится гарантированно и штатно.
for _, want := range []string{
"РАЗОШЛИСЬ", "было 2040, стало 2049",
"тренировок: было 0, стало 2", "записей: было 0, стало 2",
"покрытая разбором новая", "task down", "mv ",
} {
if !strings.Contains(out, want) { if !strings.Contains(out, want) {
t.Errorf("отчёт не содержит %q", want) t.Errorf("отчёт не содержит %q", want)
} }
+117 -14
View File
@@ -317,9 +317,11 @@ HRV); у накопительных — только `date`. Поэтому то
#### Частичный разбор #### Частичный разбор
Разбор покрывает секцию `metrics`; `workouts`, `stateOfMind`, `symptoms`, `ecg` Разбор покрывает `metrics`, `workouts` и `stateOfMind`; `symptoms`, `ecg`,
и прочие проходят мимо. Это половина потока: 48 доставок из 99 не несут `cycleTracking`, `medications` и `heartRateNotifications` проходят мимо. Живой
`metrics` вовсе (находка 50). поток последних не приносил ни разу (118 доставок: 65 с метриками, 27 с
тренировками, 26 с состоянием разума), так что сегодня непокрытая секция —
редкость, а не половина потока, как было до покрытия сущностей.
Такая доставка получает статус `partial`, а имена непокрытых секций — колонку Такая доставка получает статус `partial`, а имена непокрытых секций — колонку
`delivery.uncovered_sections`. Статус отвечает на вопрос «разобрано ли всё», `delivery.uncovered_sections`. Статус отвечает на вопрос «разобрано ли всё»,
@@ -343,7 +345,15 @@ HRV); у накопительных — только `date`. Поэтому то
покрытия на момент свёртки; доставки, свёрнутые до того, как секция стала покрытия на момент свёртки; доставки, свёрнутые до того, как секция стала
покрытой, останутся `partial` со старым списком, и ретеншен будет вечно щадить покрытой, останутся `partial` со старым списком, и ретеншен будет вечно щадить
ненужные тела. Задача, которая начинает разбирать секцию, тем же изменением ненужные тела. Задача, которая начинает разбирать секцию, тем же изменением
переводит `partial`-строки с этим ключом в `pending`. переводит `partial`-строки с этим ключом в `pending`. Так сделала миграция
`00007`, покрывшая `workouts` и `stateOfMind`.
**Следствие для ретеншена, названное вслух.** Пока `stateOfMind` был непокрыт,
его тела защищал сам статус `partial`. Теперь такая доставка получает `parsed` и
неотличима от доставки из метрик — а метрики восстановимы из экспорта Apple,
состояние разума нет (находка 46). Ретеншена в проекте нет, поэтому сегодня не
ломается ничего; но предусловие, которое задача ретеншена считала снятым, снова
открыто, и признак невосстановимости придётся завести отдельно от «непокрытости».
- **413** — тело больше допустимого. Граница стоит на **распакованном** - **413** — тело больше допустимого. Граница стоит на **распакованном**
потоке, а не только на сжатом: `MaxBytesReader` поверх `r.Body` ограничивает потоке, а не только на сжатом: `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) first_ts, last_ts, first_delivery_id, sealed, created_at, updated_at)
PK (metric, layer, hour_utc) WITHOUT ROWID PK (metric, layer, hour_utc) WITHOUT ROWID
workout(id PK, name, start_utc, end_utc, tz_offset, duration_sec, workout(id PK, name, start_utc, end_utc, tz_offset, duration_sec REAL NULL,
payload JSON, delivery_id, updated_at) 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, record(kind, id, ts_utc, tz_offset, payload BLOB, content_hash,
delivery_id, updated_at) delivery_id, delivery_received_at, created_at, updated_at)
INDEX (kind, ts_utc) PK (kind, id) INDEX (kind, ts_utc)
``` ```
Зачем пачками: Зачем пачками:
@@ -814,10 +826,17 @@ value_code "HKCategoryValueSleepAnalysisAsleepREM" ← выведено по с
### Тренировки и прочие секции ### Тренировки и прочие секции
Тренировка адресуется своим `id` из HealthKit и **перезаписывается**: она Тренировка адресуется своим `id` из HealthKit, запись — парой `род + id`.
может приехать повторно, когда доедет маршрут. `record` держит секции с `record` держит секции с собственными идентификаторами; разбором покрыт пока
собственными идентификаторами (`stateOfMind`, `ecg`, `symptoms`, только `stateOfMind`, а `ecg`, `symptoms`, `cycleTracking`, `medications` и
`cycleTracking`, `medications`, `heartRateNotifications`) — модель та же. `heartRateNotifications` остаются непокрытыми **намеренно**: живой поток не
приносил их ни разу, их формы никто не видел, а полнота покрытия HealthKit ради
полноты целью проекта не является. Модель под них заложена — новая секция
добавляется одной строкой в множество покрытых имён, а не миграцией.
Ключ записи — **пара**, а не один `id`: собственный `id` наблюдался живьём
только у `stateOfMind`, где он UUID, и короткий несквозной идентификатор в двух
разных секциях затёр бы одну запись другой молча.
Пачками они не хранятся: у них есть естественный ключ, они редки, и Пачками они не хранятся: у них есть естественный ключ, они редки, и
группировать их по часам незачем. группировать их по часам незачем.
@@ -825,12 +844,96 @@ value_code "HKCategoryValueSleepAnalysisAsleepREM" ← выведено по с
**Тренировка не разворачивается.** Заголовок — колонками, всё остальное, **Тренировка не разворачивается.** Заголовок — колонками, всё остальное,
включая маршрут и внутренние ряды, — блобом `payload`. Структура тренировки включая маршрут и внутренние ряды, — блобом `payload`. Структура тренировки
разнородна и избыточна (сводки дублируют ряды, находка 15); раскладывать её в разнородна и избыточна (сводки дублируют ряды, находка 15); раскладывать её в
таблицы значило бы решить за Apple, что в ней главное. таблицы значило бы решить за Apple, что в ней главное. Колонок ровно столько,
сколько нужно выборке: имя, интервал, офсет зоны, длительность. Длительность
берётся из тела, а не считается как `end - start` (HAE шлёт 91.746 при
интервале в 91 секунду), и её отсутствие выражается пустотой, а не нулём — ноль
законная длительность.
**Пульс приедет дважды** — в общем потоке метрики `heart_rate` и внутри **Пульс приедет дважды** — в общем потоке метрики `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`. Сегодня импорта нет, и решать это до его формы значило бы
угадывать; предел записан в беклоге отдельной задачей.
### Время ### Время
Точка внутри объекта хранится дословно, вместе с исходной строкой даты. Для Точка внутри объекта хранится дословно, вместе с исходной строкой даты. Для
+1 -1
View File
@@ -21,7 +21,6 @@
- [Порядок журнала при конкурентных приёмах](poryadok-zhurnala-na-priyome.md) — доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда — живое состояние расходится с reindex - [Порядок журнала при конкурентных приёмах](poryadok-zhurnala-na-priyome.md) — доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда — живое состояние расходится с reindex
## высокий ## высокий
- [Тренировки и секции с собственными id](trenirovki-i-zapisi.md) — Тренировки с геотреком и состояние разума приходят, но не разбираются — без них не закрыть ни трекер, ни агента-медика
- [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое - [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может - [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате - [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
@@ -31,6 +30,7 @@
- [Словарь категориальных значений → коды HealthKit](slovar-kategorialnyh-znachenij.md) — Фазы сна и типы тренировок приходят строками русской локали — с экспортом Apple их не сверить - [Словарь категориальных значений → коды HealthKit](slovar-kategorialnyh-znachenij.md) — Фазы сна и типы тренировок приходят строками русской локали — с экспортом Apple их не сверить
- [Выведенные из данных схемы содержимого](samoopisanie-shemy.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке - [Выведенные из данных схемы содержимого](samoopisanie-shemy.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
- [Импорт родного экспорта Apple Health](import-eksporta-apple.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут - [Импорт родного экспорта Apple Health](import-eksporta-apple.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
- [Идентичность тренировок при импорте родного экспорта](identichnost-trenirovok-pri-importe.md) — В export.xml у тренировки нет id — импорт снапшота задвоит тренировки, приехавшие от HAE
- [Проверка секций, которых поток ещё не приносил](proverka-novyh-sekcij.md) — Симптомы, ЭКГ, лекарства, цикл и вес не виденны живьём — разбор писался вслепую - [Проверка секций, которых поток ещё не приносил](proverka-novyh-sekcij.md) — Симптомы, ЭКГ, лекарства, цикл и вес не виденны живьём — разбор писался вслепую
- [Наблюдаемость: /stats](stats-nablyudaemost.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах - [Наблюдаемость: /stats](stats-nablyudaemost.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
- [Проверка целостности собранной витрины перед подменой](celostnost-pered-podmenoj.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`.
+7
View File
@@ -36,3 +36,10 @@
секцию, которой раньше не было, теперь **фиксируется** — остаётся научиться секцию, которой раньше не было, теперь **фиксируется** — остаётся научиться
замечать его активно: один `SELECT DISTINCT` по колонке даёт список всего, что замечать его активно: один `SELECT DISTINCT` по колонке даёт список всего, что
поток приносил, и сравнение с известным набором закрывает задачу. поток приносил, и сравнение с известным набором закрывает задачу.
Модель под секции с собственным `id` заложена (change
`2026-08-02-trenirovki-i-zapisi`): таблица `record` ключуется парой
`род + id`, и новая секция добавляется **одной строкой** в множество покрытых
имён разбора, а не миграцией. Покрыты `workouts` и `stateOfMind`; остались
`ecg`, `symptoms`, `cycleTracking`, `medications`, `heartRateNotifications`
их формы никто не видел, и разбор вслепую сознательно не писался.
+10 -1
View File
@@ -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». Связано: `docs/architecture.md` → «Read API», план → шаг «Read API».
+32 -8
View File
@@ -26,14 +26,38 @@
экспорта, записи `stateOfMind` не трогаются вовсе, а `/stats` показывает экспорта, записи `stateOfMind` не трогаются вовсе, а `/stats` показывает
глубину архива и дату снапшота, до которой он подрезан. глубину архива и дату снапшота, до которой он подрезан.
## Предусловие снято ## Предусловие снова открыто
Признак, без которого ретеншен был опасен, готов: доставка с непокрытой секцией Признак «доставка с непокрытой секцией» появился в change
имеет статус `partial` и список непокрытых ключей `2026-08-01-nerazobrannye-sekcii-dostavki` и работал заодно защитой
(change `2026-08-01-nerazobrannye-sekcii-dostavki`). Ретеншен обязан спрашивать `stateOfMind`: такие доставки числились `partial`, и ретеншен их не тронул бы.
статус, а не считать `parsed` разрешением: тело `stateOfMind` восстановить
неоткуда — в экспорте Apple секции нет. Change `2026-08-02-trenirovki-i-zapisi` покрыл `stateOfMind` разбором, и защита
исчезла: доставка из одного состояния разума теперь получает `parsed` с пустым
списком непокрытых, то есть **побайтово неотличима** от доставки из метрик — а
метрики восстановимы из экспорта Apple, состояние разума нет (находка 46).
Ретеншен, написанный по правилу «удаляем всё, что не `partial`», сотрёт ровно те
тела, которых в экспорте не существует, и первая же пересборка потеряет историю
состояния разума навсегда.
Значит признак невосстановимости нужен **не производный от «непокрытости»**.
Варианты:
- **Перечень покрытых секций, которых нет в экспорте Apple** рядом с доставкой
(сегодня — ровно `stateOfMind`). Цена: колонка и строка в свёртке; читается
так же, как `uncovered_sections`, и одним запросом.
- **Признак у доставки «тело — единственный источник»**, выставляемый разбором.
Цена та же, но смысл шире и требует решения, что считать единственным
источником для будущих секций.
- **Никогда не подрезать тела доставок, у которых есть строки в `record`.**
Цена нулевая по схеме, но неточная: провенанс записи указывает на доставку
её **текущей** версии, а копий у записи бывает по 26.
Рекомендация — первый вариант: он прямо отвечает на вопрос «что останется
потерянным», как это уже делает `uncovered_sections`, и не требует додумывать
семантику.
Вместе с этим действует правило: задача, которая начинает разбирать секцию, тем Вместе с этим действует правило: задача, которая начинает разбирать секцию, тем
же изменением переводит `partial`-строки с этим ключом в `pending`. Ретеншену же изменением переводит `partial`-строки с этим ключом в `pending` (так сделала
позволено смотреть на `partial` только пока правило соблюдается. миграция `00007`). Ретеншену позволено смотреть на `partial` только пока правило
соблюдается.
-23
View File
@@ -1,23 +0,0 @@
# Тренировки и секции с собственными id
**Приоритет:** высокий
Тренировки приезжают с геотреком, состояние разума — с кодами HealthKit. Ни то,
ни другое сейчас не разбирается. Тренировки нужны трекеру (второй сценарий),
состояние разума — агенту-медику.
Модель отличается от метрик: у этих сущностей есть собственный `id`, они редки,
и по часам их группировать незачем. Тренировка **перезаписывается** целиком —
она приезжает повторно, когда доедет маршрут.
Шаги:
- миграции `workout` и `record` (секции `stateOfMind`, `ecg`, `symptoms`,
`cycleTracking`, `medications`, `heartRateNotifications` — модель одна);
- заголовок тренировки колонками, маршрут и внутренние ряды — блобом;
- пульс внутри тренировки не смешивать с метрикой `heart_rate`: разные таблицы.
Готово, когда тренировка отдаётся одним пакетом вместе с маршрутом, а
`stateOfMind` виден записями.
Связано: `docs/architecture.md` → «Тренировки и прочие секции».
+16 -2
View File
@@ -89,8 +89,22 @@
(`internal/ident`). Сортируется по времени создания, удобен в логах и URL. (`internal/ident`). Сортируется по времени создания, удобен в логах и URL.
Разбор внешнего id — `ident.Parse` на входной границе; синтаксически Разбор внешнего id — `ident.Parse` на входной границе; синтаксически
невалидный id — 404 без похода в БД. невалидный id — 404 без похода в БД.
- Естественный ключ вместо ULID там, где он есть по природе данных: `sample` - Естественный ключ вместо ULID там, где он есть по природе данных: `workout`
и `record` — по хешу содержимого, `workout` — по `id` из HealthKit. по `id` из HealthKit, `record` — по паре `род секции + id` (форму
идентификатора у пяти из шести секций живьём никто не видел, и несквозной `id`
в двух секциях затёр бы одну запись другой молча).
- Новая единица хранения тем же изменением входит в **отпечаток витрины** и в
счётчики отчёта пересборки. Отпечаток отвечает «да/нет» за витрину целиком, и
единица, которой нет в счётчиках, делает расхождение безадресным: человек
видит «не совпало» при неизменившемся числе объектов и принимает по этому
необратимое решение о подмене базы.
- Правило выбора между двумя версиями одних данных объявляется либо **функцией
множества версий**, либо явно **функцией порядка журнала** — третьего
состояния нет. «Побеждает последняя пришедшая» третьим состоянием и является:
порядок свёртки порядку журнала не равен, и живая витрина расходится с
пересборкой молча.
- Любое значение из чужого JSON, попадающее в ключ, в лог или в отчёт, имеет
названный предел длины (имена непокрытых секций, `id` сущности).
- Временные метки — `TEXT` в RFC 3339, **UTC**, суффикс `Z`. Фиксированная - Временные метки — `TEXT` в RFC 3339, **UTC**, суффикс `Z`. Фиксированная
ширина сохраняет лексикографическую сортировку = хронологию. Единая точка ширина сохраняет лексикографическую сортировку = хронологию. Единая точка
генерации — `store.Now()`, а не дефолт в схеме: забытая вставка должна генерации — `store.Now()`, а не дефолт в схеме: забытая вставка должна
+54
View File
@@ -29,6 +29,22 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа
│ derived_layer TEXT │ └──────────────────────────────┘ │ derived_layer TEXT │ └──────────────────────────────┘
│ uncovered_sections 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` **внешним ключом не объявлена** Связь `bucket.first_delivery_id → delivery.id` **внешним ключом не объявлена**
@@ -89,3 +105,41 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа
`метрика + слой + начало + конец`, у точки-измерения конец равен началу. `метрика + слой + начало + конец`, у точки-измерения конец равен началу.
`source` в ключ не входит: он нестабилен и переписывается задним числом. При `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`, раздел «Тренировки и прочие секции».
+67
View File
@@ -1658,6 +1658,73 @@ apple_stand_time 14
Отсюда статус `partial` и колонка `delivery.uncovered_sections`: статус Отсюда статус `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, только стандартная Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная
+66
View File
@@ -246,6 +246,72 @@ func (f Fields) Relate(g Fields) Fullness {
return FullnessEqual return FullnessEqual
} }
// Covers говорит, несёт ли f всё СОДЕРЖАНИЕ g: каждый содержательный ключ g
// есть у f, и ни один верхнеуровневый массив не стал короче.
//
// Отдельно от Relate, и это не дубль. Relate гасит отношение включения до
// FullnessEqual, когда значения общих содержательных ключей разошлись, — верно
// для точки (надмножество имён при других значениях означает другое
// измерение), но неверно для сущности с собственным `id`: у неё вторая версия
// есть тот же объект, пересчитанный источником, и значения между версиями
// расходятся ВСЕГДА. Приложи Relate к тренировке — и обеднённая версия
// получила бы «равенство» и заместила бы сохранённую вместе с маршрутом
// (95% её веса), а тест на фикстуре с неизменёнными значениями остался бы
// зелёным.
//
// Длина верхнеуровневых массивов сравнивается потому, что усечённый маршрут
// (три точки вместо 593) ключа не теряет. Досчёт ряды удлиняет, поэтому
// укорачивание — законный признак «приехало меньше». Предел правила назван
// вслух: сокращение ВНУТРИ элемента ряда (точка маршрута без altitude) не
// ловится ничем, кроме сверки с телом в архиве.
//
// Длины считаются здесь, а не в Analyze: Analyze зовётся на каждый кандидат
// слияния точек, и разбор heartbeatSeries на каждой точке стоил бы дороже
// самого сравнения.
func (f Fields) Covers(g Fields) bool {
for k, gv := range g.full {
fv, ok := f.full[k]
if !ok {
return false
}
gn, gok := arrayLen(gv)
if !gok {
continue
}
fn, fok := arrayLen(fv)
if !fok || fn < gn {
return false
}
}
return true
}
// arrayLen возвращает число элементов верхнеуровневого массива. Второй возврат
// — является ли значение массивом вообще.
//
// Элементы проглатываются в выбрасываемый RawMessage: считать нужно только
// количество, а материализация маршрута в дерево значений стоила бы того же,
// от чего отказался разбор тела.
func arrayLen(raw json.RawMessage) (int, bool) {
if len(bytes.TrimSpace(raw)) == 0 || bytes.TrimSpace(raw)[0] != '[' {
return 0, false
}
dec := json.NewDecoder(bytes.NewReader(raw))
if _, err := dec.Token(); err != nil { // открывающая скобка
return 0, false
}
n := 0
for dec.More() {
var skip json.RawMessage
if err := dec.Decode(&skip); err != nil {
return 0, false
}
n++
}
return n, true
}
// agreeOnShared говорит, совпадают ли значения ключей, содержательных у обеих // agreeOnShared говорит, совпадают ли значения ключей, содержательных у обеих
// точек. Сравнение каноническое: порядок ключей и дребезг последнего разряда // точек. Сравнение каноническое: порядок ключей и дребезг последнего разряда
// расхождением не считаются. // расхождением не считаются.
+83 -26
View File
@@ -65,26 +65,28 @@ func New(arch *archive.Archive, st *store.Store, maxBody int64, log *slog.Logger
const finishTimeout = 10 * time.Second const finishTimeout = 10 * time.Second
// Stats — итог свёртки одной доставки. // Stats — итог свёртки одной доставки.
//
// Счётчики слияния ВСТРОЕНЫ, а не переписаны полем в поле: ручное копирование
// молча теряет новый счётчик, а по одному из них (удержанная обеднённая версия
// сущности) принято решение не объединять поля — забытая строка присваивания
// отменила бы наблюдение при зелёных тестах хранилища.
type Stats struct { type Stats struct {
store.MergeStats
Metrics int Metrics int
Points int Points int
Stored int
Buckets int
Unchanged int
Overwrites int
Incomparable int
SealedHits int
UnitsConflicts int
SkippedNoTime int SkippedNoTime int
SkippedMalformed int SkippedMalformed int
SkippedBadEnd int SkippedBadEnd int
// Счётчики пропуска сущностей: у каждого класса свой, потому что тело в
// архиве остаётся, а вернуть сущность может только пересборка.
SkippedNoID int
SkippedEntityNoTime int
SkippedEntityMalformed int
Layer string Layer string
LayerMismatch bool LayerMismatch bool
Collisions []store.Collision
IncomparableAt []store.Collision
// Uncovered — верхнеуровневые ключи `data`, которых разбор не покрывает. // Uncovered — верхнеуровневые ключи `data`, которых разбор не покрывает.
// Это ответ на вопрос «что останется потерянным, если тело удалить»: // Это ответ на вопрос «что останется потерянным, если тело удалить».
// для stateOfMind он необратим — в экспорте Apple этой секции нет.
Uncovered []string Uncovered []string
// UncoveredDropped — сколько имён отброшено границей списка. // UncoveredDropped — сколько имён отброшено границей списка.
UncoveredDropped int UncoveredDropped int
@@ -158,24 +160,21 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
stats.SkippedNoTime = parsed.SkippedNoTime stats.SkippedNoTime = parsed.SkippedNoTime
stats.SkippedMalformed = parsed.SkippedMalformed stats.SkippedMalformed = parsed.SkippedMalformed
stats.SkippedBadEnd = parsed.SkippedBadEnd stats.SkippedBadEnd = parsed.SkippedBadEnd
stats.SkippedNoID = parsed.SkippedNoID
stats.SkippedEntityNoTime = parsed.SkippedEntityNoTime
stats.SkippedEntityMalformed = parsed.SkippedEntityMalformed
stats.Layer = string(parsed.Layer) stats.Layer = string(parsed.Layer)
stats.LayerMismatch = parsed.LayerMismatch stats.LayerMismatch = parsed.LayerMismatch
merge, err := s.store.MergePoints(ctx, toIncoming(parsed.Points), deliveryID) merge, err := s.store.Merge(ctx, toIncoming(parsed), store.DeliveryRef{
ID: d.ID,
ReceivedAt: d.ReceivedAt,
})
if err != nil { if err != nil {
s.fail(ctx, deliveryID, err, parsed.Uncovered) s.fail(ctx, deliveryID, err, parsed.Uncovered)
return stats, err return stats, err
} }
stats.MergeStats = merge
stats.Stored = merge.Stored
stats.Buckets = merge.Buckets
stats.Unchanged = merge.Unchanged
stats.Overwrites = merge.Overwrites
stats.Incomparable = merge.Incomparable
stats.SealedHits = merge.SealedHits
stats.UnitsConflicts = merge.UnitsConflicts
stats.Collisions = merge.Collisions
stats.IncomparableAt = merge.IncomparableAt
// Источник истины — список; статус производен от него и от факта отказа. // Источник истины — список; статус производен от него и от факта отказа.
// Приоритет назван явно, иначе два будущих читателя (ретеншен и /stats) // Приоритет назван явно, иначе два будущих читателя (ретеншен и /stats)
@@ -212,6 +211,7 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
// значения. // значения.
func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) { func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
skipped := st.SkippedNoTime + st.SkippedMalformed + st.SkippedBadEnd skipped := st.SkippedNoTime + st.SkippedMalformed + st.SkippedBadEnd
skippedEntities := st.SkippedNoID + st.SkippedEntityNoTime + st.SkippedEntityMalformed
attrs := []any{ attrs := []any{
"delivery_id", deliveryID, "delivery_id", deliveryID,
@@ -228,6 +228,16 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
"skipped_no_time", st.SkippedNoTime, "skipped_no_time", st.SkippedNoTime,
"skipped_malformed", st.SkippedMalformed, "skipped_malformed", st.SkippedMalformed,
"skipped_bad_end", st.SkippedBadEnd, "skipped_bad_end", st.SkippedBadEnd,
// Сущности с собственным `id`: пришло, легло и удержано. Координаты
// (род и идентификатор) разрешены, содержимое — нет: маршрут
// тренировки это геотрек до дома, а метки состояния разума —
// измерение душевного состояния.
"workouts", st.Workouts,
"workouts_written", st.WorkoutsWritten,
"records", st.Records,
"records_written", st.RecordsWritten,
"entities_held", st.EntitiesHeld,
"skipped_entities", skippedEntities,
"layer", st.Layer, "layer", st.Layer,
"layer_mismatch", st.LayerMismatch, "layer_mismatch", st.LayerMismatch,
// Структурным []string, а не склейкой: JSON-кодировщик slog экранирует // Структурным []string, а не склейкой: JSON-кодировщик slog экранирует
@@ -242,13 +252,26 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
if len(st.IncomparableAt) > 0 { if len(st.IncomparableAt) > 0 {
attrs = append(attrs, "incomparable_at", formatCollisions(st.IncomparableAt)) attrs = append(attrs, "incomparable_at", formatCollisions(st.IncomparableAt))
} }
if len(st.HeldAt) > 0 {
attrs = append(attrs, "held_at", formatEntityRefs(st.HeldAt))
}
// Доставка, у которой отброшены ВСЕ точки, — это сломавшийся формат, а не // Доставка, у которой отброшены ВСЕ точки, — это сломавшийся формат, а не
// штатная работа. Без этого условия смена формата метки выглядела бы как // штатная работа. Без этого условия смена формата метки выглядела бы как
// здоровый поток: 200, parsed, INFO, points=0. // здоровый поток: 200, parsed, INFO, points=0.
allSkipped := st.Points == 0 && skipped > 0 allSkipped := st.Points == 0 && skipped > 0
// То же для сущностей: доставка из одних тренировок, у которой не осталось
// ни одной, — это сменившийся формат, а не пустая секция.
allEntitiesSkipped := st.Workouts == 0 && st.Records == 0 && skippedEntities > 0
switch { switch {
case st.EntitiesHeld > 0:
// Приехавшая версия сущности отклонена как теряющая содержание. Плата
// за отказ объединять поля: событие обязано быть видно, потому что на
// живом потоке оно не наступало ни разу и правило держится на этом.
s.log.WarnContext(ctx, "delivery folded, poorer entity version held", attrs...)
case allEntitiesSkipped:
s.log.WarnContext(ctx, "delivery folded, all entities skipped", attrs...)
case st.UncoveredDropped > 0: case st.UncoveredDropped > 0:
// Не частичный разбор, а тело, не похожее на HAE: секций у HAE восемь, // Не частичный разбор, а тело, не похожее на HAE: секций у HAE восемь,
// а границу выбило больше тридцати двух. // а границу выбило больше тридцати двух.
@@ -288,6 +311,16 @@ func formatCollisions(cs []store.Collision) string {
return strings.Join(parts, " ") return strings.Join(parts, " ")
} }
// formatEntityRefs превращает координаты сущностей в строку для лога.
// Содержимого не несёт: род и идентификатор — координаты, а не измерение.
func formatEntityRefs(refs []store.EntityRef) string {
parts := make([]string, 0, len(refs))
for _, r := range refs {
parts = append(parts, r.Kind+"/"+r.ID)
}
return strings.Join(parts, " ")
}
// keepLayer — значение слоя, означающее «оставить как было». // keepLayer — значение слоя, означающее «оставить как было».
const keepLayer = "" const keepLayer = ""
@@ -379,10 +412,10 @@ func (s *Service) readBody(rawPath string) ([]byte, error) {
return body, nil return body, nil
} }
func toIncoming(points []hae.Point) []store.IncomingPoint { func toIncoming(parsed hae.Result) store.Incoming {
out := make([]store.IncomingPoint, 0, len(points)) points := make([]store.IncomingPoint, 0, len(parsed.Points))
for _, p := range points { for _, p := range parsed.Points {
out = append(out, store.IncomingPoint{ points = append(points, store.IncomingPoint{
Metric: p.Metric, Metric: p.Metric,
Layer: string(p.Layer), Layer: string(p.Layer),
Units: p.Units, Units: p.Units,
@@ -394,5 +427,29 @@ func toIncoming(points []hae.Point) []store.IncomingPoint {
}, },
}) })
} }
return store.Incoming{
Points: points,
Workouts: toEntities(parsed.Workouts),
Records: toEntities(parsed.Records),
}
}
func toEntities(in []hae.Entity) []store.IncomingEntity {
if len(in) == 0 {
return nil
}
out := make([]store.IncomingEntity, 0, len(in))
for _, e := range in {
out = append(out, store.IncomingEntity{
ID: e.ID,
Kind: e.Kind,
Name: e.Name,
Start: e.Start,
End: e.End,
OffsetSeconds: e.OffsetSeconds,
Duration: e.Duration,
Raw: e.Raw,
})
}
return out return out
} }
+2 -2
View File
@@ -257,8 +257,8 @@ func TestFoldЧастичныйРазборВиденВУчёте(t *testing.T)
if d.ParseStatus != store.ParsePartial { if d.ParseStatus != store.ParsePartial {
t.Errorf("статус %q, ожидался %q", d.ParseStatus, store.ParsePartial) t.Errorf("статус %q, ожидался %q", d.ParseStatus, store.ParsePartial)
} }
if !strings.Contains(d.UncoveredSections, "stateOfMind") { if !strings.Contains(d.UncoveredSections, "ecg") {
t.Errorf("список в базе %q не содержит stateOfMind", d.UncoveredSections) t.Errorf("список в базе %q не содержит непокрытой секции", d.UncoveredSections)
} }
// Точки метрик обязаны сохраниться: частичность не отменяет разобранного. // Точки метрик обязаны сохраниться: частичность не отменяет разобранного.
if stats.Points == 0 { if stats.Points == 0 {
+130 -5
View File
@@ -221,7 +221,7 @@ func TestFoldЧастичныйРазборВЛоге(t *testing.T) {
{"date":"2025-06-05 10:07:00 +0300","qty":8}, {"date":"2025-06-05 10:07:00 +0300","qty":8},
{"date":"2025-06-05 10:08:00 +0300","qty":9}, {"date":"2025-06-05 10:08:00 +0300","qty":9},
{"date":"2025-06-05 10:09:00 +0300","qty":10}]}], {"date":"2025-06-05 10:09:00 +0300","qty":10}]}],
"stateOfMind":[{"valence":"СЕКРЕТНОЕ-НАСТРОЕНИЕ"}]}}` "ecg":[{"classification":"СЕКРЕТНЫЙ-РИТМ"}]}}`
deliver(t, arch, st, "d1", "Minutes", "a1", []byte(body)) deliver(t, arch, st, "d1", "Minutes", "a1", []byte(body))
if _, err := f.Fold(ctx, "d1"); err != nil { if _, err := f.Fold(ctx, "d1"); err != nil {
@@ -229,10 +229,10 @@ func TestFoldЧастичныйРазборВЛоге(t *testing.T) {
} }
out := buf.String() out := buf.String()
if !strings.Contains(out, "stateOfMind") { if !strings.Contains(out, "ecg") {
t.Error("имени непокрытой секции нет в логе — момент появления новой секции незаметен") t.Error("имени непокрытой секции нет в логе — момент появления новой секции незаметен")
} }
if strings.Contains(out, "СЕКРЕТНОЕ-НАСТРОЕНИЕ") { if strings.Contains(out, "СЕКРЕТНЫЙ-РИТМ") {
t.Error("содержимое непокрытой секции утекло в лог") t.Error("содержимое непокрытой секции утекло в лог")
} }
@@ -250,7 +250,132 @@ func TestFoldЧастичныйРазборВЛоге(t *testing.T) {
if rec.Level != "INFO" { if rec.Level != "INFO" {
t.Errorf("уровень %q, ожидался INFO: частичность — не отклонение", rec.Level) t.Errorf("уровень %q, ожидался INFO: частичность — не отклонение", rec.Level)
} }
if len(rec.Uncovered) != 1 || rec.Uncovered[0] != "stateOfMind" { if len(rec.Uncovered) != 1 || rec.Uncovered[0] != "ecg" {
t.Errorf("атрибут uncovered = %v, ожидался структурный список из stateOfMind", rec.Uncovered) t.Errorf("атрибут uncovered = %v, ожидался структурный список из ecg", rec.Uncovered)
}
}
// Содержимое сущности чувствительнее значения точки: маршрут тренировки — это
// геотрек до дома, а метки состояния разума — измерение душевного состояния.
// Разрешены только координаты: род, идентификатор, интервал.
func TestFoldНеПишетСодержимогоСущностейВЛог(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
dir := t.TempDir()
arch, err := archive.New(filepath.Join(dir, "raw"))
if err != nil {
t.Fatalf("архив: %v", err)
}
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
if err != nil {
t.Fatalf("база: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
f := fold.New(arch, st, 0, log)
ctx := context.Background()
const full = `{"data":{"workouts":[{"id":"w-открытый","name":"На улице Ходьба",` +
`"start":"2025-06-05 10:00:00 +0300","end":"2025-06-05 10:10:00 +0300",` +
`"route":[{"latitude":55.987654,"longitude":37.123456},{"latitude":55.987655,"longitude":37.123457}],` +
`"totalEnergy":{"qty":404040.4}}],` +
`"stateOfMind":[{"id":"e-открытый","start":"2025-06-05T18:00:00Z",` +
`"labels":["СЕКРЕТНАЯ-ЭМОЦИЯ"],"valence":0.777777}]}}`
// Вторая доставка теряет маршрут и меняет значения — та самая ветка, где
// пишется WARN об удержанной версии и где велик соблазн приписать «что
// именно потерялось».
const poorer = `{"data":{"workouts":[{"id":"w-открытый","name":"На улице Ходьба",` +
`"start":"2025-06-05 10:00:00 +0300","end":"2025-06-05 10:10:00 +0300",` +
`"totalEnergy":{"qty":505050.5}}]}}`
deliver(t, arch, st, "d1", "Minutes", "auto-1", []byte(full))
if _, err := f.Fold(ctx, "d1"); err != nil {
t.Fatalf("свёртка первой доставки: %v", err)
}
deliver(t, arch, st, "d2", "Minutes", "auto-1", []byte(poorer))
if _, err := f.Fold(ctx, "d2"); err != nil {
t.Fatalf("свёртка второй доставки: %v", err)
}
logged := buf.String()
for _, secret := range []string{"55.98", "37.12", "latitude", "СЕКРЕТНАЯ-ЭМОЦИЯ", "404040", "505050", "0.777777"} {
if strings.Contains(logged, secret) {
t.Errorf("в логе оказалось %q:\n%s", secret, logged)
}
}
// Координаты, наоборот, обязаны быть: без них счётчик удержанных версий не
// говорит, какая сущность пострадала.
if !strings.Contains(logged, "w-открытый") {
t.Error("координат удержанной сущности в логе нет")
}
if !strings.Contains(logged, "poorer entity version held") {
t.Error("удержание обеднённой версии не отмечено записью WARN")
}
}
// Счётчики разбора доезжают до лога ручным присваиванием, и забытая строка
// молча выключила бы наблюдение — тот самый класс, ради которого счётчики
// слияния встроены структурой. Тест закрепляет имена атрибутов.
func TestFoldСчётчикиСущностейДоезжаютДоЛога(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
dir := t.TempDir()
arch, err := archive.New(filepath.Join(dir, "raw"))
if err != nil {
t.Fatalf("архив: %v", err)
}
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
if err != nil {
t.Fatalf("база: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
f := fold.New(arch, st, 0, log)
// Одна годная тренировка, одна без `id`, одна с неразбираемой меткой и один
// элемент, не являющийся объектом: три класса пропуска плюс успех.
const body = `{"data":{"workouts":[
{"id":"w1","start":"2025-06-05 10:00:00 +0300","end":"2025-06-05 10:10:00 +0300"},
{"start":"2025-06-05 11:00:00 +0300"},
{"id":"w3","start":"позавчера"},
"строка вместо объекта"]}}`
deliver(t, arch, st, "d1", "Minutes", "auto-1", []byte(body))
if _, err := f.Fold(context.Background(), "d1"); err != nil {
t.Fatalf("свёртка: %v", err)
}
var rec struct {
Workouts int `json:"workouts"`
WorkoutsWritten int `json:"workouts_written"`
Records int `json:"records"`
RecordsWritten int `json:"records_written"`
EntitiesHeld int `json:"entities_held"`
SkippedEntities int `json:"skipped_entities"`
}
line := strings.TrimSpace(buf.String())
if i := strings.LastIndex(line, "\n"); i >= 0 {
line = line[i+1:]
}
if err := json.Unmarshal([]byte(line), &rec); err != nil {
t.Fatalf("запись лога не разбирается: %v", err)
}
if rec.Workouts != 1 || rec.WorkoutsWritten != 1 {
t.Errorf("тренировок %d, записано %d — ожидалось 1 и 1", rec.Workouts, rec.WorkoutsWritten)
}
if rec.SkippedEntities != 3 {
t.Errorf("пропущено сущностей %d, ожидалось 3 — счётчик не доехал до лога", rec.SkippedEntities)
}
if rec.Records != 0 || rec.RecordsWritten != 0 || rec.EntitiesHeld != 0 {
t.Errorf("лишние счётчики: записей %d/%d, удержано %d",
rec.Records, rec.RecordsWritten, rec.EntitiesHeld)
} }
} }
+144
View File
@@ -0,0 +1,144 @@
package hae
import (
"bytes"
"encoding/json"
"strconv"
"time"
)
// maxEntityID — предел длины идентификатора сущности.
//
// `id` приходит из тела, которым отправитель управляет целиком, а уезжает и в
// первичный ключ таблицы, и в записи лога. UUID HealthKit — 36 байт, так что
// запас велик; правило то же, что уже действует для имён непокрытых секций, и
// оно снимает класс, а не случай.
const maxEntityID = 128
// rfc3339Layout — второй формат метки, которым HAE шлёт stateOfMind
// (находка 16). Метрики и тренировки идут первым, `timeLayout`.
const rfc3339Layout = time.RFC3339
// entityHead — поля сущности, нужные разбору. Всё остальное остаётся в Raw и
// хранится дословно.
//
// Длительность читается сырым сообщением, а не числом: нечисловое значение —
// это пропуск ОДНОГО поля, а не сломанная сущность, и типизированное поле
// уводило бы всю тренировку в счётчик «не разобралась как объект».
type entityHead struct {
ID string `json:"id"`
Name string `json:"name"`
Date string `json:"date"`
Start string `json:"start"`
End string `json:"end"`
Duration json.RawMessage `json:"duration"`
}
// decodeEntities разбирает элементы одной покрытой секции в сущности.
//
// Пропуск одного элемента не уносит соседей: у каждого класса пропуска свой
// счётчик, тело остаётся в архиве, и доставку вернёт пересборка, когда разбор
// научится понимать пропущенное.
func decodeEntities(raws []json.RawMessage, kind string, res *Result) []Entity {
if len(raws) == 0 {
return nil
}
out := make([]Entity, 0, len(raws))
for _, raw := range raws {
var head entityHead
if err := json.Unmarshal(raw, &head); err != nil {
res.SkippedEntityMalformed++
continue
}
if head.ID == "" || len(head.ID) > maxEntityID {
res.SkippedNoID++
continue
}
start, ok := parseEntityTime(firstNonEmpty(head.Start, head.Date))
if !ok {
res.SkippedEntityNoTime++
continue
}
// Конец, которого нет или который не читается, равен началу. У точки то
// же вырождение запрещено — там оно схлопнуло бы две записи в одну
// координату, — а сущность адресуется своим `id`, и схлопывать нечего.
// Истина при этом остаётся в Raw дословно.
end := start
if head.End != "" {
if e, ok := parseEntityTime(head.End); ok {
end = e
}
}
_, offset := start.Zone()
e := Entity{
ID: head.ID,
Kind: kind,
Name: head.Name,
Start: start.UTC(),
End: end.UTC(),
OffsetSeconds: offset,
Duration: parseDuration(head.Duration),
Raw: raw,
}
out = append(out, e)
}
if len(out) == 0 {
return nil
}
return out
}
// parseEntityTime разбирает метку сущности, принимая оба измеренных формата.
//
// Оба, а не приписанный секции: формы однозначны и не пересекаются (RFC 3339
// несёт `T` и `Z`), а HAE выравнивает секции между собой по ходу своих
// обновлений — `stateOfMind` уже шлёт стабильные коды HealthKit там, где старые
// секции шлют переводы. Приписанный секции формат ломался бы молча в день
// такого выравнивания.
//
// Метка ТОЧКИ остаётся строгой (parseTime), и асимметрия намеренная: по метке
// точки выводится слой, причём по метке в исходной зоне. Терпимость там
// означала бы, что метка в UTC тихо портит выравнивание и часовая выгрузка
// складывается с минутной; у сущности слоя нет, и терять на строгости нечего.
func parseEntityTime(s string) (time.Time, bool) {
if s == "" {
return time.Time{}, false
}
if t, err := time.Parse(timeLayout, s); err == nil {
return t, true
}
if t, err := time.Parse(rfc3339Layout, s); err == nil {
return t, true
}
return time.Time{}, false
}
// parseDuration переводит длительность в секунды.
//
// Отсутствие и нечисловое значение дают nil, а не ноль: ноль — законная
// длительность, и потребитель, сложивший столбец, иначе не отличил бы
// «источник не прислал» от «измерено ноль». Вычислять длительность из
// интервала нельзя: HAE шлёт 91.746 при интервале в 91 секунду.
func parseDuration(raw json.RawMessage) *float64 {
lit := bytes.TrimSpace(raw)
if len(lit) == 0 {
return nil
}
v, err := strconv.ParseFloat(string(lit), 64)
if err != nil {
return nil
}
return &v
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
+318
View File
@@ -0,0 +1,318 @@
package hae_test
import (
"encoding/json"
"strings"
"testing"
"git.vakhrushev.me/av/healthlog/internal/hae"
)
func parseFixture(t *testing.T, name string) hae.Result {
t.Helper()
res, err := hae.Parse(load(t, name), hae.Meta{})
if err != nil {
t.Fatalf("разбор %s: %v", name, err)
}
return res
}
// Тренировка хранится дословно: маршрут и внутренние ряды остаются теми же
// байтами, какими пришли. Раскладывать их по колонкам значило бы решить за
// Apple, что в тренировке главное.
func TestParseТренировкаСМаршрутом(t *testing.T) {
t.Parallel()
res := parseFixture(t, "workout_route.json")
var w hae.Entity
for _, e := range res.Workouts {
if strings.Contains(string(e.Raw), `"route"`) {
w = e
}
}
if w.ID == "" {
t.Fatal("уличной тренировки с маршрутом в фикстуре не нашлось")
}
if w.Name == "" {
t.Error("имя пусто — по нему идёт выборка заголовков")
}
if !w.End.After(w.Start) {
t.Errorf("интервал %v — %v", w.Start, w.End)
}
if w.OffsetSeconds != 3*3600 {
t.Errorf("офсет %d, ожидался 10800", w.OffsetSeconds)
}
if w.Duration == nil || *w.Duration <= 0 {
t.Errorf("длительность %v — обязана браться из тела", w.Duration)
}
var body map[string]json.RawMessage
if err := json.Unmarshal(w.Raw, &body); err != nil {
t.Fatalf("содержимое не разбирается: %v", err)
}
for _, key := range []string{"route", "heartRateData", "activeEnergy", "heartRateRecovery"} {
if _, ok := body[key]; !ok {
t.Errorf("в содержимом нет %q — внутренние ряды обязаны храниться дословно", key)
}
}
// Точки маршрута проходят исходными байтами: их метка (`timestamp`)
// меткой сущности не является и не разбирается.
if !strings.Contains(string(body["route"]), "timestamp") {
t.Error("точки маршрута потеряли своё поле времени")
}
}
// Пульс приезжает дважды — в общем потоке метрик и внутри тренировки. Это
// разные таблицы; смешение задвоило бы ряд.
func TestParseРядПульсаТренировкиНеСтановитсяМетрикой(t *testing.T) {
t.Parallel()
res := parseFixture(t, "workout_route.json")
if len(res.Points) != 0 {
t.Errorf("точек %d, ожидалось 0: доставка несёт только тренировки", len(res.Points))
}
if res.Metrics != 0 {
t.Errorf("метрик %d, ожидалось 0", res.Metrics)
}
if len(res.Uncovered) != 0 {
t.Errorf("непокрытые %v, ожидался пустой список", res.Uncovered)
}
}
// Набор полей тренировки зависит от её типа: у домашней нет маршрута, зато
// есть температура и влажность. Фиксированной схемы не существует.
func TestParseТренировкаБезМаршрута(t *testing.T) {
t.Parallel()
res := parseFixture(t, "workout_indoor.json")
if len(res.Workouts) == 0 {
t.Fatal("тренировок нет")
}
var body map[string]json.RawMessage
if err := json.Unmarshal(res.Workouts[0].Raw, &body); err != nil {
t.Fatalf("содержимое не разбирается: %v", err)
}
if _, ok := body["route"]; ok {
t.Error("у домашней тренировки взялся маршрут")
}
for _, key := range []string{"temperature", "humidity", "intensity"} {
if _, ok := body[key]; !ok {
t.Errorf("в содержимом нет %q", key)
}
}
}
// stateOfMind живёт по другим соглашениям: RFC 3339 в UTC, коды HealthKit
// вместо переводов, поля source нет вовсе.
func TestParseСостояниеРазума(t *testing.T) {
t.Parallel()
res := parseFixture(t, "state_of_mind.json")
if len(res.Records) == 0 {
t.Fatal("записей нет")
}
for _, r := range res.Records {
if r.Kind != "stateOfMind" {
t.Errorf("род %q, ожидался stateOfMind — имя секции хранится дословно", r.Kind)
}
if r.ID == "" {
t.Error("идентификатор пуст")
}
// HAE прислал UTC — офсет ноль. Это значит «источник прислал UTC», а не
// «человек был в Гринвиче»: местной зоны у секции в потоке нет вовсе.
if r.OffsetSeconds != 0 {
t.Errorf("офсет %d, ожидался 0", r.OffsetSeconds)
}
if r.Start.IsZero() {
t.Error("метка не разобралась — RFC 3339 обязан приниматься")
}
}
if len(res.Uncovered) != 0 {
t.Errorf("непокрытые %v, ожидался пустой список", res.Uncovered)
}
}
// Пропуск одного элемента не уносит соседей, и у каждого класса свой счётчик:
// тело остаётся в архиве, а вернуть сущность может только пересборка.
func TestParseКраевыеСлучаиСущностей(t *testing.T) {
t.Parallel()
res := parseFixture(t, "handmade_entities.json")
byID := make(map[string]hae.Entity, len(res.Workouts))
for _, w := range res.Workouts {
byID[w.ID] = w
}
// Пустой id, отсутствующий id и id длиннее предела — один счётчик на три
// случая: исход у них общий.
if res.SkippedNoID != 3 {
t.Errorf("пропущено по идентификатору %d, ожидалось 3", res.SkippedNoID)
}
// Метка не разбирается и метки нет вовсе.
if res.SkippedEntityNoTime != 2 {
t.Errorf("пропущено по метке %d, ожидалось 2", res.SkippedEntityNoTime)
}
// Элемент, не являющийся объектом.
if res.SkippedEntityMalformed != 1 {
t.Errorf("пропущено по форме %d, ожидалось 1", res.SkippedEntityMalformed)
}
t.Run("нечитаемый конец не отбрасывает тренировку", func(t *testing.T) {
w, ok := byID["00000000-0000-4000-8000-000000000003"]
if !ok {
t.Fatal("тренировка с нечитаемым концом потерялась целиком")
}
if !w.End.Equal(w.Start) {
t.Errorf("конец %v, ожидался равным началу %v", w.End, w.Start)
}
if !strings.Contains(string(w.Raw), "никогда") {
t.Error("исходное значение конца не сохранилось дословно")
}
})
t.Run("нечисловая длительность не становится нулём", func(t *testing.T) {
w := byID["00000000-0000-4000-8000-000000000004"]
if w.Duration != nil {
t.Errorf("длительность %v, ожидалось отсутствие", *w.Duration)
}
})
t.Run("отсутствующая длительность отличима от нуля", func(t *testing.T) {
w := byID["00000000-0000-4000-8000-000000000005"]
if w.Duration != nil {
t.Errorf("длительность %v, ожидалось отсутствие", *w.Duration)
}
})
t.Run("начало берётся из date, когда start отсутствует", func(t *testing.T) {
w, ok := byID["00000000-0000-4000-8000-000000000006"]
if !ok {
t.Fatal("тренировка с меткой в date потерялась")
}
if w.Start.IsZero() {
t.Error("метка не разобралась")
}
})
t.Run("незнакомое поле переживает разбор дословно", func(t *testing.T) {
w := byID["00000000-0000-4000-8000-000000000009"]
for _, lit := range []string{"невиданноеПоле", "1.0", "9007199254740993", "0.123456789012345678"} {
if !strings.Contains(string(w.Raw), lit) {
t.Errorf("литерал %q потерян при разборе", lit)
}
}
})
t.Run("запись со временем в формате метрик тоже разбирается", func(t *testing.T) {
var daily *hae.Entity
for i, r := range res.Records {
if strings.Contains(string(r.Raw), "daily_mood") {
daily = &res.Records[i]
}
}
if daily == nil {
t.Fatal("запись daily_mood потерялась")
}
if daily.OffsetSeconds != 3*3600 {
t.Errorf("офсет %d, ожидался 10800: формат метрик обязан приниматься", daily.OffsetSeconds)
}
})
}
// Отказ разбора — операция «всё или ничего»: ошибка после уже разобранной
// секции не имеет права оставить сущности в результате.
func TestParseОбрывПослеСекцииТренировокНеОтдаётСущностей(t *testing.T) {
t.Parallel()
const body = `{"data":{"workouts":[{"id":"w1","start":"2025-06-05 10:00:00 +0300"}],"metrics":`
res, err := hae.Parse([]byte(body), hae.Meta{})
if err == nil {
t.Fatal("оборванное тело разобралось без ошибки")
}
if len(res.Workouts) != 0 {
t.Errorf("сущностей %d, ожидалось 0", len(res.Workouts))
}
}
// Невыводимый слой — тоже «всё или ничего»: доставка целиком уходит в failed,
// иначе она получила бы failed при частично записанной витрине.
func TestParseНевыводимыйСлойНеОтдаётСущностей(t *testing.T) {
t.Parallel()
const body = `{"data":{
"metrics":[{"name":"vo2_max","units":"ml/kg*min","data":[{"date":"2025-06-05 10:11:12 +0300","qty":1}]}],
"stateOfMind":[{"id":"e1","start":"2025-06-05T18:00:00Z","valence":0.5}]}}`
res, err := hae.Parse([]byte(body), hae.Meta{Aggregation: "Default"})
if err == nil {
t.Fatal("доставка без выводимого слоя разобралась без ошибки")
}
if len(res.Records) != 0 {
t.Errorf("записей %d, ожидалось 0", len(res.Records))
}
if len(res.Points) != 0 {
t.Errorf("точек %d, ожидалось 0", len(res.Points))
}
}
// Повтор ключа покрытой секции объединяет её, а не отдаёт победу последней.
func TestParseПовторСекцииТренировокОбъединяет(t *testing.T) {
t.Parallel()
const body = `{"data":{
"workouts":[{"id":"w1","start":"2025-06-05 10:00:00 +0300"}],
"workouts":[{"id":"w2","start":"2025-06-05 11:00:00 +0300"}]}}`
res, err := hae.Parse([]byte(body), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if len(res.Workouts) != 2 {
t.Errorf("тренировок %d, ожидалось 2 — секции обязаны объединиться", len(res.Workouts))
}
}
// Повтор самого члена `data` накапливает результаты: присваивание теряло бы
// секции первого члена молча — их имена уже отмечены и во второй список не
// попали бы.
func TestParseПовторЧленаDataНакапливает(t *testing.T) {
t.Parallel()
const body = `{"data":{"ecg":[{"id":"e"}]},"data":{"workouts":[{"id":"w1","start":"2025-06-05 10:00:00 +0300"}]}}`
res, err := hae.Parse([]byte(body), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if len(res.Workouts) != 1 {
t.Errorf("тренировок %d, ожидалась 1", len(res.Workouts))
}
if len(res.Uncovered) != 1 || res.Uncovered[0] != "ecg" {
t.Errorf("непокрытые %v, ожидался [ecg] — иначе секция потеряна молча", res.Uncovered)
}
}
// Непокрытой секцией с собственными `id` остаются те, чьей формы никто не
// видел: разбор вслепую хуже честного «не покрыто».
func TestParseНепокрытыеСекцииССобственнымиID(t *testing.T) {
t.Parallel()
const body = `{"data":{"ecg":[{"id":"e1","start":"2025-06-05 10:00:00 +0300"}]}}`
res, err := hae.Parse([]byte(body), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if len(res.Records) != 0 {
t.Errorf("записей %d, ожидалось 0", len(res.Records))
}
if len(res.Uncovered) != 1 || res.Uncovered[0] != "ecg" {
t.Errorf("непокрытые %v", res.Uncovered)
}
}
+164 -42
View File
@@ -95,12 +95,66 @@ type Point struct {
local time.Time local time.Time
} }
// Entity — сущность с собственным идентификатором: тренировка или запись
// секции вроде stateOfMind. От точки отличается тем, что её адресует сам `id`,
// а не координаты, и слоя у неё нет вовсе: подробности выгрузки у этих секций
// в интерфейсе HAE не бывает.
type Entity struct {
// ID — идентификатор из HealthKit. Приходит из тела и ограничен по длине:
// уезжает и в первичный ключ, и в записи лога.
ID string
// Kind — верхнеуровневый ключ секции HAE ДОСЛОВНО (`stateOfMind`, не
// `state_of_mind`): инвариант «форма Apple не транслируется» относится и к
// именам секций, а переименование после того, как значение легло в базу,
// стоило бы миграции данных. У тренировки род один и в ключ не входит.
Kind string
// Name — имя тренировки как прислал HAE, локализованное («В помещении
// Ходьба»). У записей пустое.
Name string
// Start и End — координаты в UTC. Конец, которого нет или который не
// читается, равен началу: ключ сущности — `id`, схлопывать нечего, а истина
// остаётся в Raw. У точки то же вырождение запрещено — там оно схлопнуло бы
// две записи в одну координату.
Start time.Time
End time.Time
// OffsetSeconds — смещение зоны НАЧАЛА. Колонка одна, а тренировка через
// смену зоны дала бы два разных.
OffsetSeconds int
// Duration — длительность тренировки в секундах, как прислал HAE. Не
// вычисляется из интервала: HAE шлёт 91.746 при интервале в 91 секунду.
// Отсутствие выражается nil, а не нулём: ноль — законная длительность.
Duration *float64
// Raw — содержимое сущности исходными байтами, как пришло в теле, включая
// маршрут и внутренние ряды.
Raw json.RawMessage
}
// Result — итог разбора доставки. Частичные исходы живут в счётчиках, а не в // Result — итог разбора доставки. Частичные исходы живут в счётчиках, а не в
// ошибке: пакет, у которого не разобралась одна точка из тысячи, — обычное // ошибке: пакет, у которого не разобралась одна точка из тысячи, — обычное
// дело, и терять из-за неё остальное нельзя. // дело, и терять из-за неё остальное нельзя.
type Result struct { type Result struct {
Points []Point Points []Point
// Workouts и Records — сущности с собственным идентификатором. Разведены,
// потому что у тренировки есть заголовок (имя, интервал, длительность), по
// которому идёт выборка, а у записи его нет.
Workouts []Entity
Records []Entity
// SkippedNoID — сущности без пригодного идентификатора: пустого, нет вовсе
// или длиннее предела. Один счётчик на все три случая: исход у них общий, а
// различает их только тело, лежащее в архиве.
SkippedNoID int
// SkippedEntityNoTime — сущности с идентификатором, но без разбираемой
// метки времени.
SkippedEntityNoTime int
// SkippedEntityMalformed — элементы секции, не разобравшиеся как объект.
SkippedEntityMalformed int
// Uncovered — верхнеуровневые ключи `data`, которых разбор не покрывает, // Uncovered — верхнеуровневые ключи `data`, которых разбор не покрывает,
// отсортированные и без повторов. Половина живого потока состоит из таких // отсортированные и без повторов. Половина живого потока состоит из таких
// доставок целиком (48 из 99: workouts и stateOfMind), и без этого списка // доставок целиком (48 из 99: workouts и stateOfMind), и без этого списка
@@ -170,13 +224,17 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
} }
}() }()
metrics, uncovered, dropped, err := decodeEnvelope(body) env, err := decodeEnvelope(body)
if err != nil { if err != nil {
return Result{}, err return Result{}, err
} }
res.Uncovered = uncovered res.Uncovered = env.uncovered
res.UncoveredDropped = dropped res.UncoveredDropped = env.dropped
res.Workouts = decodeEntities(env.workouts, workoutsSection, &res)
res.Records = decodeEntities(env.stateOfMind, stateOfMindSection, &res)
metrics := env.metrics
res.Metrics = len(metrics) res.Metrics = len(metrics)
if len(metrics) == 0 { if len(metrics) == 0 {
return res, nil return res, nil
@@ -209,6 +267,12 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
// определился слой, обязана остаться записью о том, что в теле есть // определился слой, обязана остаться записью о том, что в теле есть
// невосстановимая секция. Иначе ретеншен увидит failed без списка и // невосстановимая секция. Иначе ретеншен увидит failed без списка и
// решит, что терять нечего. // решит, что терять нечего.
//
// Сущности при этом НЕ отдаются, хотя слоя у них нет и разобрались они
// успешно. «Всё или ничего» относится к доставке, а не к точкам: отдай
// мы их, доставка получила бы `failed` при частично записанной витрине,
// и повторная свёртка перестала бы быть no-op. Цена названа в спеке —
// такая доставка доедет пересборкой, а тело ждёт в архиве.
return Result{ return Result{
Metrics: res.Metrics, Metrics: res.Metrics,
Uncovered: res.Uncovered, Uncovered: res.Uncovered,
@@ -262,16 +326,53 @@ type group struct {
// 42 МиБ через map[string]any удерживает 197 МиБ кучи против 54 МиБ у этой // 42 МиБ через map[string]any удерживает 197 МиБ кучи против 54 МиБ у этой
// формы. Вместе с самим телом пик доходил бы до ~300 МиБ на доставку — это // формы. Вместе с самим телом пик доходил бы до ~300 МиБ на доставку — это
// OOM ровно на пике потока, когда терять доставки дороже всего. // OOM ровно на пике потока, когда терять доставки дороже всего.
// metricsSection — единственная секция, которую разбор покрывает сегодня. // Секции, которые разбор покрывает. Прочие секции с собственными `id` (`ecg`,
const metricsSection = "metrics" // `symptoms`, `cycleTracking`, `medications`, `heartRateNotifications`)
// покрытыми намеренно не становятся: живой поток не приносил их ни разу, их
// форма никем не наблюдалась, а полнота покрытия HealthKit ради полноты целью
// проекта не является.
const (
metricsSection = "metrics"
workoutsSection = "workouts"
stateOfMindSection = "stateOfMind"
)
// covered говорит, покрывает ли разбор секцию с таким именем. // decodeCovered разбирает секцию, если разбор её покрывает; второй возврат
// говорит, взялся ли он за неё.
// //
// Функция, а не изменяемая карта: разбор и перечисление непокрытых ходят по // Один источник и для разбора, и для перечисления непокрытых: перечисляющий
// одному источнику, поэтому состояние «секция разбирается, но числится // спрашивает ровно того, кто разбирает, поэтому состояние «секция разбирается,
// непокрытой» невыразимо. // но числится непокрытой» невыразимо по построению. Отдельный предикат
func covered(section string) bool { // `covered` разошёлся бы с этим switch при первой же новой секции.
return section == metricsSection //
// Повтор ключа покрытой секции JSON допускает; секции ОБЪЕДИНЯЮТСЯ, а не
// побеждает последняя: терять данные молча нельзя.
func decodeCovered(name string, dec *json.Decoder, env *envelope) (bool, error) {
switch name {
case metricsSection:
var part []metricEnvelope
if err := dec.Decode(&part); err != nil {
return true, err
}
env.metrics = append(env.metrics, part...)
return true, nil
case workoutsSection:
part, err := decodeSection(dec)
if err != nil {
return true, err
}
env.workouts = append(env.workouts, part...)
return true, nil
case stateOfMindSection:
part, err := decodeSection(dec)
if err != nil {
return true, err
}
env.stateOfMind = append(env.stateOfMind, part...)
return true, nil
default:
return false, nil
}
} }
// Границы на список непокрытых ключей. Тело контролирует отправитель целиком: // Границы на список непокрытых ключей. Тело контролирует отправитель целиком:
@@ -317,9 +418,11 @@ type pointHead struct {
// мгновенно. Разбор `data` в map[string]json.RawMessage дешевле по коду, но // мгновенно. Разбор `data` в map[string]json.RawMessage дешевле по коду, но
// копирует байты ВСЕХ секций и держит их до конца разбора; у проглатывания // копирует байты ВСЕХ секций и держит их до конца разбора; у проглатывания
// копия одна и живёт до следующего члена. // копия одна и живёт до следующего члена.
func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string, dropped int, err error) { func decodeEnvelope(body []byte) (envelope, error) {
fail := func(e error) ([]metricEnvelope, []string, int, error) { var env envelope
return nil, nil, 0, fmt.Errorf("%w: %v", ErrMalformed, e) //nolint:errorlint // причина уходит в лог, наружу не раскрывается
fail := func(e error) (envelope, error) {
return envelope{}, fmt.Errorf("%w: %v", ErrMalformed, e) //nolint:errorlint // причина уходит в лог, наружу не раскрывается
} }
dec := json.NewDecoder(bytes.NewReader(body)) dec := json.NewDecoder(bytes.NewReader(body))
@@ -335,7 +438,7 @@ func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string,
// раскладывал его в пустую структуру. Границы поведения этой задачей не // раскладывал его в пустую структуру. Границы поведения этой задачей не
// двигаются — она добавляет список, а не строгость. // двигаются — она добавляет список, а не строгость.
if tok == nil { if tok == nil {
return nil, nil, 0, nil return envelope{}, nil
} }
if d, ok := tok.(json.Delim); !ok || d != '{' { if d, ok := tok.(json.Delim); !ok || d != '{' {
return fail(fmt.Errorf("ожидался объект, встречено %v", tok)) return fail(fmt.Errorf("ожидался объект, встречено %v", tok))
@@ -352,8 +455,12 @@ func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string,
} }
continue continue
} }
metrics, uncovered, dropped, err = decodeData(dec, seen) // Повтор самого члена `data` JSON допускает, и результаты
if err != nil { // НАКАПЛИВАЮТСЯ, а не замещаются: присваивание теряло бы секции первого
// члена целиком, причём молча — их имена уже отмечены в `seen` и во
// второй список непокрытых не попали бы. Правило то же, что уровнем
// ниже для повтора ключа секции.
if err := decodeData(dec, seen, &env); err != nil {
return fail(err) return fail(err)
} }
} }
@@ -363,60 +470,75 @@ func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string,
// Список канонизируется: порядок ключей в JSON от HAE нестабилен, а // Список канонизируется: порядок ключей в JSON от HAE нестабилен, а
// значение уезжает в базу и сравнивается между доставками. // значение уезжает в базу и сравнивается между доставками.
sort.Strings(uncovered) sort.Strings(env.uncovered)
return metrics, uncovered, dropped, nil return env, nil
} }
// decodeData разбирает объект data, собирая metrics и имена непокрытых секций. // envelope — что разбор вынул из тела: покрытые секции и имена непокрытых.
func decodeData(dec *json.Decoder, seen map[string]struct{}) ([]metricEnvelope, []string, int, error) { type envelope struct {
metrics []metricEnvelope
workouts []json.RawMessage
stateOfMind []json.RawMessage
uncovered []string
dropped int
}
// decodeData разбирает объект data, дописывая в конверт покрытые секции и
// имена непокрытых.
func decodeData(dec *json.Decoder, seen map[string]struct{}, env *envelope) error {
tok, err := dec.Token() tok, err := dec.Token()
if err != nil { if err != nil {
return nil, nil, 0, err return err
} }
// data не объект — прежнее поведение: ошибка ровно там, где была. // data не объект — прежнее поведение: ошибка ровно там, где была.
if d, ok := tok.(json.Delim); !ok || d != '{' { if d, ok := tok.(json.Delim); !ok || d != '{' {
return nil, nil, 0, fmt.Errorf("data: ожидался объект, встречено %v", tok) return fmt.Errorf("data: ожидался объект, встречено %v", tok)
} }
var (
metrics []metricEnvelope
uncovered []string
dropped int
)
for dec.More() { for dec.More() {
name, err := memberName(dec) name, err := memberName(dec)
if err != nil { if err != nil {
return nil, nil, 0, err return err
} }
if covered(name) { handled, err := decodeCovered(name, dec, env)
// Повтор ключа metrics JSON допускает; секции ОБЪЕДИНЯЮТСЯ, а не if err != nil {
// побеждает последняя: терять точки молча нельзя. return err
var part []metricEnvelope
if err := dec.Decode(&part); err != nil {
return nil, nil, 0, err
} }
metrics = append(metrics, part...) if handled {
continue continue
} }
if err := swallow(dec); err != nil { if err := swallow(dec); err != nil {
return nil, nil, 0, err return err
} }
if _, dup := seen[name]; dup { if _, dup := seen[name]; dup {
continue continue
} }
seen[name] = struct{}{} seen[name] = struct{}{}
if len(uncovered) >= maxUncovered { if len(env.uncovered) >= maxUncovered {
dropped++ env.dropped++
continue continue
} }
uncovered = append(uncovered, clipSection(name)) env.uncovered = append(env.uncovered, clipSection(name))
} }
if _, err := dec.Token(); err != nil { // закрывающая скобка data if _, err := dec.Token(); err != nil { // закрывающая скобка data
return nil, nil, 0, err return err
} }
return metrics, uncovered, dropped, nil return nil
}
// decodeSection читает секцию сущностей элементами исходных байтов.
//
// Разбор до `json.RawMessage`, а не до структуры: сущность хранится дословно, и
// декодирование в типизированное значение потеряло бы литерал — ровно то, от
// чего защищает `Point.Raw`.
func decodeSection(dec *json.Decoder) ([]json.RawMessage, error) {
var part []json.RawMessage
if err := dec.Decode(&part); err != nil {
return nil, err
}
return part, nil
} }
// memberName читает имя члена объекта. Token() отдаёт имя уже после разбора // memberName читает имя члена объекта. Token() отдаёт имя уже после разбора
+12 -11
View File
@@ -548,10 +548,11 @@ func FuzzParse(f *testing.F) {
}) })
} }
// Половина живого потока состоит из непокрытых секций целиком (48 доставок из // Непокрытая секция неотличима от разобранной доставки с пустой секцией
// 99: workouts и stateOfMind). Без списка они неотличимы от разобранной // метрик, если её не назвать: ретеншен, ориентируясь на статус, срезал бы
// доставки с пустой секцией метрик, и ретеншен, ориентируясь на статус, срезал // тела, которые для секций без экспорта Apple единственный источник. С тех пор
// бы тела, которые для stateOfMind единственный источник. // как workouts и stateOfMind стали покрытыми, роль непокрытой в фикстуре
// играют секции, которых поток ещё не приносил.
func TestParseПеречисляетНепокрытыеСекции(t *testing.T) { func TestParseПеречисляетНепокрытыеСекции(t *testing.T) {
t.Parallel() t.Parallel()
@@ -560,7 +561,7 @@ func TestParseПеречисляетНепокрытыеСекции(t *testing.
t.Fatalf("разбор: %v", err) t.Fatalf("разбор: %v", err)
} }
want := []string{"stateOfMind", "workouts"} want := []string{"ecg", "symptoms"}
if !slices.Equal(res.Uncovered, want) { if !slices.Equal(res.Uncovered, want) {
t.Errorf("непокрытые %v, ожидались %v", res.Uncovered, want) t.Errorf("непокрытые %v, ожидались %v", res.Uncovered, want)
} }
@@ -578,11 +579,11 @@ func TestParseНепокрытыеСекцииГраницыИДетермини
t.Run("доставка из одной непокрытой секции", func(t *testing.T) { t.Run("доставка из одной непокрытой секции", func(t *testing.T) {
t.Parallel() t.Parallel()
res, err := hae.Parse([]byte(`{"data":{"stateOfMind":[{"x":1}]}}`), hae.Meta{}) res, err := hae.Parse([]byte(`{"data":{"ecg":[{"x":1}]}}`), hae.Meta{})
if err != nil { if err != nil {
t.Fatalf("разбор: %v", err) t.Fatalf("разбор: %v", err)
} }
if !slices.Equal(res.Uncovered, []string{"stateOfMind"}) { if !slices.Equal(res.Uncovered, []string{"ecg"}) {
t.Errorf("непокрытые %v", res.Uncovered) t.Errorf("непокрытые %v", res.Uncovered)
} }
if len(res.Points) != 0 { if len(res.Points) != 0 {
@@ -609,11 +610,11 @@ func TestParseНепокрытыеСекцииГраницыИДетермини
t.Parallel() t.Parallel()
bodies := []string{ bodies := []string{
`{"data":{"workouts":[],"stateOfMind":[],"ecg":[]}}`, `{"data":{"symptoms":[],"medications":[],"ecg":[]}}`,
`{"data":{"ecg":[],"workouts":[],"stateOfMind":[]}}`, `{"data":{"ecg":[],"symptoms":[],"medications":[]}}`,
`{"data":{"stateOfMind":[],"ecg":[],"workouts":[],"ecg":[]}}`, `{"data":{"medications":[],"ecg":[],"symptoms":[],"ecg":[]}}`,
} }
want := []string{"ecg", "stateOfMind", "workouts"} want := []string{"ecg", "medications", "symptoms"}
for _, b := range bodies { for _, b := range bodies {
res, err := hae.Parse([]byte(b), hae.Meta{}) res, err := hae.Parse([]byte(b), hae.Meta{})
if err != nil { if err != nil {
+13 -2
View File
@@ -116,6 +116,13 @@ func TestParseУдержаниеКучиНепокрытойСекции(t *test
t.Logf("непокрытых %v, удержано %d МиБ при теле %d МиБ", t.Logf("непокрытых %v, удержано %d МиБ при теле %d МиБ",
res.Uncovered, retained>>20, len(body)>>20) res.Uncovered, retained>>20, len(body)>>20)
// Замер обязан идти по ветке проглатывания. Без этой проверки расширение
// множества покрытых секций превращает сторож в зелёную пустышку — что уже
// однажды и произошло.
if len(res.Uncovered) == 0 {
t.Fatal("непокрытых секций нет — замер идёт мимо проглатывания и ничего не сторожит")
}
if retained > limit { if retained > limit {
t.Errorf("удержано %d МиБ при теле %d МиБ — больше четырёх тел; "+ t.Errorf("удержано %d МиБ при теле %d МиБ — больше четырёх тел; "+
"похоже, секции удерживаются, а не проглатываются", "похоже, секции удерживаются, а не проглатываются",
@@ -149,9 +156,13 @@ func bodyWithUncovered(size int) []byte {
var b strings.Builder var b strings.Builder
b.WriteString(`{"data":{"metrics":[{"name":"m","units":"count","data":[`) b.WriteString(`{"data":{"metrics":[{"name":"m","units":"count","data":[`)
b.WriteString(`{"date":"2025-06-05 10:00:00 +0300","qty":1}`) b.WriteString(`{"date":"2025-06-05 10:00:00 +0300","qty":1}`)
b.WriteString(`]}],"stateOfMind":[`) // Секция обязана быть ЗАВЕДОМО НЕПОКРЫТОЙ: тело из покрытой секции идёт
// мимо проглатывания, и замер вырождается в ноль, оставаясь зелёным.
// Так уже случилось однажды: здесь стоял `stateOfMind`, и задача, покрывшая
// его разбором, обезоружила сторож молча.
b.WriteString(`]}],"ecg":[`)
const entry = `{"id":"00000000-0000-0000-0000-000000000000","kind":"momentary_emotion","valence":0.5},` const entry = `{"id":"00000000-0000-0000-0000-000000000000","classification":"sinusRhythm"},`
for b.Len() < size { for b.Len() < size {
b.WriteString(entry) b.WriteString(entry)
} }
+21
View File
@@ -12,6 +12,19 @@
переставлены с сохранением формы), даты (сдвинуты на постоянную величину), переставлены с сохранением формы), даты (сдвинуты на постоянную величину),
имена устройств. имена устройств.
Для сущностей с собственным `id` вычищается дополнительно: сами
идентификаторы (псевдо-UUID той же формы), метки RFC 3339 и `route[].timestamp`
(сдвигаются, как и прочие даты), а также словарные значения `stateOfMind`
`kind`, `valenceClassification`, `labels`, `associations`. Последнее не
перестраховка: это измерение душевного состояния, самое чувствительное, что
есть в потоке. Значения подменяются другими кодами из того же словаря HealthKit,
поэтому форма (snake_case, строка против массива строк) сохраняется, а смысл —
нет. Побочный эффект подмены: в `labels` могут появиться повторы, которых HAE не
шлёт; разбору это безразлично.
Координаты маршрута вычищаются как обычные числа — они не отличаются от прочих
измерений и после подмены указывают в никуда.
| Файл | Что проверяет | | Файл | Что проверяет |
|---|---| |---|---|
| `minute.json` | минутная доставка; плотные метрики минутные, одна (`apple_stand_hour`) часовая — классификация **по метрике**, а не по доставке; редкие наследуют минутный слой; суточная сводка сна | | `minute.json` | минутная доставка; плотные метрики минутные, одна (`apple_stand_hour`) часовая — классификация **по метрике**, а не по доставке; редкие наследуют минутный слой; суточная сводка сна |
@@ -20,7 +33,12 @@
| `mixed.json` | одна доставка с минутными, посекундными и часовыми метриками — перенастройка автоматизации | | `mixed.json` | одна доставка с минутными, посекундными и часовыми метриками — перенастройка автоматизации |
| `heartbeat_series.json` | точка с `heartbeatSeries`: третий формат времени, серия проходит исходными байтами | | `heartbeat_series.json` | точка с `heartbeatSeries`: третий формат времени, серия проходит исходными байтами |
| `sparse_sleep.json` | доставка **без плотных метрик** (заголовок `Default` не спасает) и поэпизодный сон с задвоенной меткой — интервальная идентичность | | `sparse_sleep.json` | доставка **без плотных метрик** (заголовок `Default` не спасает) и поэпизодный сон с задвоенной меткой — интервальная идентичность |
| `workout_route.json` | уличная тренировка с маршрутом и внутренними рядами; ряды урезаны `--limit`, форма точки маршрута сохранена |
| `workout_indoor.json` | тренировка без маршрута: набор полей зависит от типа (`temperature`, `humidity`, `intensity` вместо `route`, `avgSpeed`, `flightsClimbed`) |
| `state_of_mind.json` | `stateOfMind`: RFC 3339 в UTC, коды вместо переводов, поля `source` нет вовсе |
| `uncovered_sections.json` | одна доставка со всеми родами секций сразу — покрытыми и непокрытыми. Рукотворная: живой поток шлёт по одной секции за раз |
| `handmade_edge.json` | случаи, которых живой поток не даёт (см. ниже) | | `handmade_edge.json` | случаи, которых живой поток не даёт (см. ниже) |
| `handmade_entities.json` | краевые случаи сущностей: пустой, отсутствующий и слишком длинный `id`; неразбираемая метка и её отсутствие; неразбираемый `end`; нечисловая и отсутствующая длительность; две версии одного `id` в одном теле; элемент, не являющийся объектом |
`handmade_edge.json` собран руками, скриптом не воспроизводится: `handmade_edge.json` собран руками, скриптом не воспроизводится:
@@ -43,3 +61,6 @@ python3 tmp/research/fixtures.py --list
python3 tmp/research/fixtures.py <файл из data/raw> --metrics a,b --limit 14 \ python3 tmp/research/fixtures.py <файл из data/raw> --metrics a,b --limit 14 \
--out internal/hae/testdata/<имя>.json --out internal/hae/testdata/<имя>.json
``` ```
`--limit` режет и внутренние ряды тренировки (маршрут, пульс, энергия): фикстуре
нужна форма точки ряда, а не 593 её экземпляра.
+110
View File
@@ -0,0 +1,110 @@
{
"_comment": "Рукотворная фикстура: краевые случаи сущностей с собственным id, которых живой поток не даёт. Скриптом tmp/research/fixtures.py не порождается, значения выдуманы целиком.",
"data": {
"workouts": [
{
"id": "",
"name": "Пустой идентификатор",
"start": "2025-06-05 10:00:00 +0300",
"end": "2025-06-05 10:10:00 +0300",
"duration": 600
},
{
"name": "Идентификатора нет вовсе",
"start": "2025-06-05 10:00:00 +0300",
"end": "2025-06-05 10:10:00 +0300"
},
{
"id": "00000000-0000-4000-8000-000000000001",
"name": "Метка не разбирается",
"start": "вчера вечером",
"end": "2025-06-05 10:10:00 +0300"
},
{
"id": "00000000-0000-4000-8000-000000000002",
"name": "Метки нет вовсе",
"duration": 60
},
{
"id": "00000000-0000-4000-8000-000000000003",
"name": "Конец не разбирается",
"start": "2025-06-05 11:00:00 +0300",
"end": "никогда",
"duration": 61.5
},
{
"id": "00000000-0000-4000-8000-000000000004",
"name": "Длительность не число",
"start": "2025-06-05 12:00:00 +0300",
"end": "2025-06-05 12:01:00 +0300",
"duration": "минута"
},
{
"id": "00000000-0000-4000-8000-000000000005",
"name": "Длительности нет",
"start": "2025-06-05 13:00:00 +0300",
"end": "2025-06-05 13:01:00 +0300"
},
{
"id": "00000000-0000-4000-8000-000000000006",
"name": "Начало берётся из date",
"date": "2025-06-05 14:00:00 +0300",
"duration": 30
},
{
"id": "00000000-0000-4000-8000-000000000007",
"name": "Две версии в одном теле, первая",
"start": "2025-06-05 15:00:00 +0300",
"end": "2025-06-05 15:30:00 +0300",
"duration": 1800,
"totalEnergy": {"qty": 100.5, "units": "kJ"}
},
{
"id": "00000000-0000-4000-8000-000000000007",
"name": "Две версии в одном теле, вторая",
"start": "2025-06-05 15:00:00 +0300",
"end": "2025-06-05 15:30:00 +0300",
"duration": 1800,
"totalEnergy": {"qty": 110.5, "units": "kJ"}
},
{
"id": "00000000-0000-4000-8000-000000000008-и-ещё-очень-длинный-хвост-который-заведомо-выходит-за-предел-длины-идентификатора-принятый-разбором-сущностей",
"name": "Идентификатор длиннее предела",
"start": "2025-06-05 16:00:00 +0300",
"end": "2025-06-05 16:01:00 +0300"
},
"не объект вовсе",
{
"id": "00000000-0000-4000-8000-000000000009",
"name": "Незнакомое поле и дословные литералы",
"start": "2025-06-05 17:00:00 +0300",
"end": "2025-06-05 17:05:00 +0300",
"duration": 300,
"невиданноеПоле": {"вложенное": [1.0, 9007199254740993, 0.123456789012345678]},
"isIndoor": false
}
],
"stateOfMind": [
{
"id": "00000000-0000-4000-8000-00000000000a",
"kind": "momentary_emotion",
"start": "2025-06-05T18:00:00Z",
"end": "2025-06-05T18:00:00Z",
"valence": 0.5,
"valenceClassification": "pleasant",
"labels": ["calm"],
"associations": ["hobbies"]
},
{
"id": "00000000-0000-4000-8000-00000000000b",
"kind": "daily_mood",
"start": "2025-06-05 21:00:00 +0300",
"end": "2025-06-06 21:00:00 +0300",
"valence": -0.25,
"valenceClassification": "slightly_unpleasant",
"labels": [],
"associations": []
}
]
}
}
+36
View File
@@ -0,0 +1,36 @@
{
"data": {
"stateOfMind": [
{
"associations": [
"fitness"
],
"valenceClassification": "slightly_unpleasant",
"valence": 0.021376147736425923,
"end": "2025-06-05T18:03:51Z",
"labels": [
"peaceful",
"peaceful"
],
"kind": "momentary_emotion",
"id": "50244944-3582-4508-7804-525425479700",
"start": "2025-06-05T18:03:51Z"
},
{
"kind": "daily_mood",
"end": "2025-06-05T18:03:17Z",
"id": "87868435-7832-4736-7633-078876535422",
"associations": [
"hobbies"
],
"start": "2025-06-05T18:03:17Z",
"valence": 0.12057562819279189,
"valenceClassification": "slightly_unpleasant",
"labels": [
"relieved",
"relieved"
]
}
]
}
}
+90 -15
View File
@@ -5,16 +5,56 @@
"name": "step_count", "name": "step_count",
"units": "count", "units": "count",
"data": [ "data": [
{"date": "2025-06-05 09:00:00 +0300", "qty": 41.0, "source": "Device A"}, {
{"date": "2025-06-05 09:01:00 +0300", "qty": 17.0, "source": "Device A"}, "date": "2025-06-05 09:00:00 +0300",
{"date": "2025-06-05 09:02:00 +0300", "qty": 82.0, "source": "Device A"}, "qty": 41.0,
{"date": "2025-06-05 09:03:00 +0300", "qty": 5.0, "source": "Device A"}, "source": "Device A"
{"date": "2025-06-05 09:04:00 +0300", "qty": 63.0, "source": "Device A"}, },
{"date": "2025-06-05 09:05:00 +0300", "qty": 28.0, "source": "Device A"}, {
{"date": "2025-06-05 09:06:00 +0300", "qty": 94.0, "source": "Device A"}, "date": "2025-06-05 09:01:00 +0300",
{"date": "2025-06-05 09:07:00 +0300", "qty": 12.0, "source": "Device A"}, "qty": 17.0,
{"date": "2025-06-05 09:08:00 +0300", "qty": 71.0, "source": "Device A"}, "source": "Device A"
{"date": "2025-06-05 09:09:00 +0300", "qty": 36.0, "source": "Device A"} },
{
"date": "2025-06-05 09:02:00 +0300",
"qty": 82.0,
"source": "Device A"
},
{
"date": "2025-06-05 09:03:00 +0300",
"qty": 5.0,
"source": "Device A"
},
{
"date": "2025-06-05 09:04:00 +0300",
"qty": 63.0,
"source": "Device A"
},
{
"date": "2025-06-05 09:05:00 +0300",
"qty": 28.0,
"source": "Device A"
},
{
"date": "2025-06-05 09:06:00 +0300",
"qty": 94.0,
"source": "Device A"
},
{
"date": "2025-06-05 09:07:00 +0300",
"qty": 12.0,
"source": "Device A"
},
{
"date": "2025-06-05 09:08:00 +0300",
"qty": 71.0,
"source": "Device A"
},
{
"date": "2025-06-05 09:09:00 +0300",
"qty": 36.0,
"source": "Device A"
}
] ]
} }
], ],
@@ -26,11 +66,28 @@
"end": "2025-06-05 08:30:00 +0300", "end": "2025-06-05 08:30:00 +0300",
"duration": 1800, "duration": 1800,
"heartRateData": [ "heartRateData": [
{"date": "2025-06-05 08:00:07 +0300", "Min": 91.0, "Avg": 94.5, "Max": 98.0, "units": "count/min"}, {
{"date": "2025-06-05 08:00:21 +0300", "Min": 93.0, "Avg": 95.5, "Max": 99.0, "units": "count/min"} "date": "2025-06-05 08:00:07 +0300",
"Min": 91.0,
"Avg": 94.5,
"Max": 98.0,
"units": "count/min"
},
{
"date": "2025-06-05 08:00:21 +0300",
"Min": 93.0,
"Avg": 95.5,
"Max": 99.0,
"units": "count/min"
}
], ],
"route": [ "route": [
{"lat": 10.0, "lon": 20.0, "altitude": 30.0, "timestamp": "2025-06-05 08:00:07 +0300"} {
"lat": 10.0,
"lon": 20.0,
"altitude": 30.0,
"timestamp": "2025-06-05 08:00:07 +0300"
}
] ]
} }
], ],
@@ -41,9 +98,27 @@
"end": "2025-06-05T05:12:33Z", "end": "2025-06-05T05:12:33Z",
"kind": "momentary_emotion", "kind": "momentary_emotion",
"valence": 0.25, "valence": 0.25,
"labels": ["slightly_pleasant"], "labels": [
"slightly_pleasant"
],
"associations": [] "associations": []
} }
],
"ecg": [
{
"id": "00000000-0000-0000-0000-000000000003",
"start": "2025-06-05 07:00:00 +0300",
"classification": "sinusRhythm"
}
],
"symptoms": [
{
"id": "00000000-0000-0000-0000-000000000004",
"start": "2025-06-05 06:00:00 +0300",
"name": "Головная боль",
"severity": "mild"
}
] ]
} },
"_comment": "Рукотворная фикстура: одна доставка со всеми родами секций сразу — покрытыми (metrics, workouts, stateOfMind) и непокрытыми (ecg, symptoms). Живой поток такого не даёт: автоматизация HAE шлёт одну секцию за раз."
} }
+176
View File
@@ -0,0 +1,176 @@
{
"data": {
"workouts": [
{
"id": "19336133-5198-0770-0219-503343909539",
"maxHeartRate": {
"qty": 199,
"units": "count/min"
},
"temperature": {
"units": "degC",
"qty": 25.642900109344569
},
"name": "В помещении Ходьба",
"avgHeartRate": {
"qty": 47.271142352144859,
"units": "count/min"
},
"isIndoor": true,
"duration": 11.104378534563007,
"walkingAndRunningDistance": [
{
"qty": 0.051178653222924688,
"date": "2025-06-05 21:07:25 +0300",
"units": "km",
"source": "Device A  "
},
{
"qty": 0.0022744856624713684,
"date": "2025-06-05 21:08:25 +0300",
"units": "km",
"source": "Device A  "
}
],
"intensity": {
"qty": 1.1101433306047975,
"units": "kcal/hr·kg"
},
"start": "2025-06-05 21:07:25 +0300",
"end": "2025-06-05 21:08:56 +0300",
"activeEnergy": [
{
"units": "kJ",
"date": "2025-06-05 21:07:25 +0300",
"qty": 49.484435766191329,
"source": "Device A  "
},
{
"date": "2025-06-05 21:08:25 +0300",
"qty": 1.2893292371894432,
"units": "kJ",
"source": "Device A  "
}
],
"basalEnergy": [
{
"units": "kJ",
"qty": 8.118214886809112,
"date": "2025-06-05 21:07:25 +0300",
"source": "Device A  "
},
{
"units": "kJ",
"date": "2025-06-05 21:08:25 +0300",
"source": "Device A  ",
"qty": 8.3170473912094565
}
],
"location": "В помещении",
"metadata": {},
"distance": {
"units": "km",
"qty": 0.097916506499049191
},
"totalEnergy": {
"qty": 66.474914481955616,
"units": "kJ"
},
"activeEnergyBurned": {
"units": "kJ",
"qty": 21.260499065517783
},
"speed": {
"qty": 3.842565481147650,
"units": "km/hr"
},
"heartRateData": [
{
"Max": 199,
"date": "2025-06-05 21:07:25 +0300",
"Avg": 86.126023892474565,
"source": "Device A  ",
"Min": 68,
"units": "count/min"
},
{
"Min": 62,
"source": "Device A  ",
"date": "2025-06-05 21:08:25 +0300",
"Avg": 42.420241306988357,
"Max": 11,
"units": "count/min"
}
],
"heartRateRecovery": [
{
"Avg": 18,
"units": "count/min",
"Max": 18,
"date": "2025-06-05 21:09:01 +0300",
"Min": 18,
"source": "Device A  "
},
{
"Min": 18,
"date": "2025-06-05 21:09:05 +0300",
"Avg": 18,
"Max": 18,
"units": "count/min",
"source": "Device A  "
},
{
"Max": 18,
"date": "2025-06-05 21:09:07 +0300",
"Avg": 18,
"source": "Device A  ",
"Min": 18,
"units": "count/min"
},
{
"Max": 64,
"Avg": 64,
"Min": 64,
"source": "Device A  |Device B",
"date": "2025-06-05 21:10:44 +0300",
"units": "count/min"
},
{
"Avg": 84,
"Max": 84,
"units": "count/min",
"Min": 84,
"source": "Device A  |Device B",
"date": "2025-06-05 21:10:49 +0300"
},
{
"source": "Device A  |Device B",
"Min": 16,
"units": "count/min",
"Max": 16,
"Avg": 16,
"date": "2025-06-05 21:10:54 +0300"
}
],
"humidity": {
"units": "%",
"qty": 49
},
"heartRate": {
"max": {
"qty": 199,
"units": "count/min"
},
"avg": {
"qty": 47.271142352144859,
"units": "count/min"
},
"min": {
"qty": 68,
"units": "count/min"
}
}
}
]
}
}
+588
View File
@@ -0,0 +1,588 @@
{
"data": {
"workouts": [
{
"elevationDown": {
"qty": 58,
"units": "m"
},
"end": "2025-06-06 10:14:23 +0300",
"activeEnergy": [
{
"qty": 19.843076834478356,
"units": "kJ",
"source": "Device A  ",
"date": "2025-06-06 10:04:28 +0300"
},
{
"source": "Device A  ",
"date": "2025-06-06 10:05:28 +0300",
"qty": 36.215835452964045,
"units": "kJ"
},
{
"qty": 44.740245661570856,
"source": "Device A  ",
"date": "2025-06-06 10:06:28 +0300",
"units": "kJ"
},
{
"qty": 11.685483741512438,
"units": "kJ",
"source": "Device A  ",
"date": "2025-06-06 10:11:28 +0300"
},
{
"date": "2025-06-06 10:12:28 +0300",
"source": "Device A  ",
"units": "kJ",
"qty": 57.203753970215048
},
{
"date": "2025-06-06 10:13:28 +0300",
"units": "kJ",
"qty": 92.751506515081775,
"source": "Device A  "
}
],
"basalEnergy": [
{
"qty": 1.7719543711823085,
"date": "2025-06-06 10:04:28 +0300",
"units": "kJ",
"source": "Device A  "
},
{
"units": "kJ",
"source": "Device A  ",
"date": "2025-06-06 10:05:28 +0300",
"qty": 3.5941548161054400
},
{
"source": "Device A  ",
"date": "2025-06-06 10:06:28 +0300",
"units": "kJ",
"qty": 8.4754498192994867
},
{
"units": "kJ",
"source": "Device A  ",
"date": "2025-06-06 10:11:28 +0300",
"qty": 3.5994877058893775
},
{
"source": "Device A  ",
"units": "kJ",
"qty": 1.3821773496019762,
"date": "2025-06-06 10:12:28 +0300"
},
{
"source": "Device A  ",
"qty": 3.5575445948756234,
"units": "kJ",
"date": "2025-06-06 10:13:28 +0300"
}
],
"maxSpeed": {
"qty": 1.7500239301072799,
"units": "km"
},
"heartRate": {
"avg": {
"qty": 580.51441585109356,
"units": "count/min"
},
"min": {
"units": "count/min",
"qty": 304
},
"max": {
"units": "count/min",
"qty": 149
}
},
"heartRateRecovery": [
{
"Min": 530,
"units": "count/min",
"Avg": 530,
"source": "Device A  ",
"date": "2025-06-06 10:14:26 +0300",
"Max": 530
},
{
"source": "Device A  ",
"Max": 509,
"Min": 509,
"units": "count/min",
"Avg": 509,
"date": "2025-06-06 10:14:29 +0300"
},
{
"Min": 103,
"Avg": 103,
"Max": 103,
"date": "2025-06-06 10:14:37 +0300",
"units": "count/min",
"source": "Device A  "
},
{
"Avg": 744,
"date": "2025-06-06 10:16:12 +0300",
"source": "Device A  ",
"Max": 744,
"Min": 744,
"units": "count/min"
},
{
"Min": 744,
"units": "count/min",
"Avg": 744,
"source": "Device A  ",
"Max": 744,
"date": "2025-06-06 10:16:17 +0300"
},
{
"source": "Device A  ",
"Max": 556,
"Min": 556,
"Avg": 556,
"units": "count/min",
"date": "2025-06-06 10:16:22 +0300"
}
],
"metadata": {},
"heartRateData": [
{
"Min": 304,
"Avg": 378.98897969841650,
"date": "2025-06-06 10:04:28 +0300",
"units": "count/min",
"Max": 147,
"source": "Device A  "
},
{
"Avg": 989.39259425621223,
"source": "Device A  ",
"units": "count/min",
"Max": 485,
"date": "2025-06-06 10:05:28 +0300",
"Min": 933
},
{
"Min": 859,
"source": "Device A  ",
"date": "2025-06-06 10:06:28 +0300",
"Max": 485,
"units": "count/min",
"Avg": 763.09312691469744
},
{
"date": "2025-06-06 10:11:28 +0300",
"Min": 925,
"source": "Device A  ",
"Avg": 930.87887543899777,
"units": "count/min",
"Max": 744
},
{
"Max": 136,
"Avg": 194.26919436633345,
"units": "count/min",
"date": "2025-06-06 10:12:28 +0300",
"Min": 225,
"source": "Device A  "
},
{
"Min": 136,
"source": "Device A  ",
"units": "count/min",
"date": "2025-06-06 10:13:28 +0300",
"Max": 149,
"Avg": 503.16594564319518
}
],
"totalEnergy": {
"qty": 492.41220115508732,
"units": "kJ"
},
"isIndoor": false,
"avgSpeed": {
"qty": 1.3262919151335835,
"units": "km"
},
"id": "88509471-0669-5670-1525-588189120267",
"flightsClimbed": {
"qty": 3,
"units": "count"
},
"avgHeartRate": {
"units": "count/min",
"qty": 580.51441585109356
},
"route": [
{
"speedAccuracy": 1.1423493491291924,
"courseAccuracy": -8,
"horizontalAccuracy": 37.394709386127353,
"course": -8,
"speed": 6.370761754418105,
"longitude": 19.710543914850393,
"altitude": 11.113585029955223,
"timestamp": "2025-06-06 10:04:31 +0300",
"latitude": 19.536524809019373,
"verticalAccuracy": 1.1014334704737449
},
{
"speedAccuracy": 3.6635325607233020,
"timestamp": "2025-06-06 10:04:32 +0300",
"longitude": 42.942268598619119,
"horizontalAccuracy": 60.170973672383984,
"verticalAccuracy": 13.574261948723033,
"speed": 0,
"courseAccuracy": -8,
"altitude": 95.460693244383061,
"course": -8,
"latitude": 55.963120726050803
},
{
"altitude": 98.38291790977828,
"speed": 0.24397521532364589,
"course": -8,
"speedAccuracy": 0.61821384896158206,
"courseAccuracy": -8,
"latitude": 89.43783944027558,
"verticalAccuracy": 9,
"timestamp": "2025-06-06 10:04:33 +0300",
"longitude": 38.436129962995469,
"horizontalAccuracy": 7.0942497223602135
},
{
"timestamp": "2025-06-06 10:14:21 +0300",
"latitude": 13.560422352014850,
"verticalAccuracy": 9,
"speed": 0.43710174633556862,
"course": 138.44753628780219,
"speedAccuracy": 0.9626723648822914,
"horizontalAccuracy": 4.2411766781980601,
"courseAccuracy": 974.54474058674578,
"longitude": 16.879590022407365,
"altitude": 27.895395296040802
},
{
"courseAccuracy": 77.457067793746674,
"altitude": 68.180480909094274,
"course": 260.10957552178519,
"verticalAccuracy": 9,
"horizontalAccuracy": 8.6389325539171599,
"speedAccuracy": 0.66244428495829519,
"speed": 0.73309170745262559,
"latitude": 25.591167408963750,
"longitude": 84.179710558888322,
"timestamp": "2025-06-06 10:14:22 +0300"
},
{
"verticalAccuracy": 9,
"course": 626.70356760825397,
"altitude": 35.541136651044451,
"latitude": 16.540540353133693,
"horizontalAccuracy": 6.5267643473591963,
"courseAccuracy": 28.348317314778372,
"longitude": 54.812184540297820,
"timestamp": "2025-06-06 10:14:23 +0300",
"speed": 0.50936788164417521,
"speedAccuracy": 0.6056449968288769
}
],
"speed": {
"units": "km/hr",
"qty": 1.8553272323937848
},
"distance": {
"qty": 0.45535883774475302,
"units": "km"
},
"location": "На улице",
"stepCount": [
{
"date": "2025-06-06 10:04:28 +0300",
"units": "count",
"qty": 578.41514946060714,
"source": "Device A  "
},
{
"source": "Device A  ",
"qty": 888.32265222984684,
"date": "2025-06-06 10:05:28 +0300",
"units": "count"
},
{
"units": "count",
"date": "2025-06-06 10:06:28 +0300",
"qty": 389.76300874239161,
"source": "Device A  "
},
{
"qty": 856.17715741799695,
"source": "Device A  |Device B",
"date": "2025-06-06 10:11:28 +0300",
"units": "count"
},
{
"date": "2025-06-06 10:12:28 +0300",
"source": "Device A  |Device B",
"units": "count",
"qty": 139.21282675041696
},
{
"qty": 35.029304391445824,
"date": "2025-06-06 10:13:28 +0300",
"units": "count",
"source": "Device A  |Device B"
}
],
"duration": 914.68731011451610,
"name": "На улице Ходьба",
"start": "2025-06-06 10:04:28 +0300",
"activeEnergyBurned": {
"qty": 103.57106764020185,
"units": "kJ"
},
"walkingAndRunningDistance": [
{
"date": "2025-06-06 10:04:28 +0300",
"qty": 0.013796342030844388,
"source": "Device A  ",
"units": "km"
},
{
"qty": 0.074232043027597205,
"units": "km",
"date": "2025-06-06 10:05:28 +0300",
"source": "Device A  "
},
{
"qty": 0.026811024049784521,
"units": "km",
"date": "2025-06-06 10:06:28 +0300",
"source": "Device A  "
},
{
"date": "2025-06-06 10:11:28 +0300",
"units": "km",
"source": "Device A  |Device B",
"qty": 0.014294089476468883
},
{
"units": "km",
"qty": 0.01426458841320012,
"source": "Device A  |Device B",
"date": "2025-06-06 10:12:28 +0300"
},
{
"date": "2025-06-06 10:13:28 +0300",
"source": "Device A  |Device B",
"qty": 0.050652946782496564,
"units": "km"
}
],
"stepCadence": {
"units": "count/min",
"qty": 494.09651343334029
},
"maxHeartRate": {
"units": "count/min",
"qty": 149
}
},
{
"distance": {
"qty": 0.097916506499049191,
"units": "km"
},
"name": "В помещении Ходьба",
"activeEnergyBurned": {
"units": "kJ",
"qty": 21.260499065517783
},
"temperature": {
"units": "degC",
"qty": 25.642900109344569
},
"stepCount": [
{
"qty": 36.176779283023652,
"units": "count",
"source": "Device A  ",
"date": "2025-06-05 21:07:25 +0300"
},
{
"date": "2025-06-05 21:08:25 +0300",
"qty": 41.246641544770624,
"units": "count",
"source": "Device A  "
}
],
"stepCadence": {
"qty": 67.312908402573276,
"units": "count/min"
},
"isIndoor": true,
"basalEnergy": [
{
"date": "2025-06-05 21:07:25 +0300",
"units": "kJ",
"qty": 8.118214886809112,
"source": "Device A  "
},
{
"source": "Device A  ",
"date": "2025-06-05 21:08:25 +0300",
"units": "kJ",
"qty": 4.9653503151527592
}
],
"activeEnergy": [
{
"date": "2025-06-05 21:07:25 +0300",
"qty": 49.484435766191329,
"source": "Device A  ",
"units": "kJ"
},
{
"qty": 8.3710606151472032,
"units": "kJ",
"source": "Device A  ",
"date": "2025-06-05 21:08:25 +0300"
}
],
"id": "19336133-5198-0770-0219-503343909539",
"intensity": {
"units": "kcal/hr·kg",
"qty": 1.1101433306047975
},
"heartRateData": [
{
"units": "count/min",
"Min": 68,
"source": "Device A  ",
"Max": 199,
"date": "2025-06-05 21:07:25 +0300",
"Avg": 86.126023892474565
},
{
"units": "count/min",
"source": "Device A  ",
"Max": 11,
"Min": 62,
"date": "2025-06-05 21:08:25 +0300",
"Avg": 42.420241306988357
}
],
"end": "2025-06-05 21:08:56 +0300",
"location": "В помещении",
"speed": {
"qty": 3.842565481147650,
"units": "km/hr"
},
"totalEnergy": {
"qty": 67.010279114793536,
"units": "kJ"
},
"maxHeartRate": {
"units": "count/min",
"qty": 199
},
"avgHeartRate": {
"qty": 47.271142352144859,
"units": "count/min"
},
"heartRateRecovery": [
{
"Min": 18,
"Avg": 18,
"source": "Device A  ",
"Max": 18,
"units": "count/min",
"date": "2025-06-05 21:09:01 +0300"
},
{
"date": "2025-06-05 21:09:05 +0300",
"source": "Device A  ",
"Avg": 18,
"units": "count/min",
"Min": 18,
"Max": 18
},
{
"Min": 18,
"source": "Device A  ",
"units": "count/min",
"Max": 18,
"date": "2025-06-05 21:09:07 +0300",
"Avg": 18
},
{
"Max": 64,
"Min": 64,
"Avg": 64,
"units": "count/min",
"source": "Device A  |Device B",
"date": "2025-06-05 21:10:44 +0300"
},
{
"Min": 84,
"units": "count/min",
"date": "2025-06-05 21:10:49 +0300",
"Max": 84,
"Avg": 84,
"source": "Device A  |Device B"
},
{
"units": "count/min",
"source": "Device A  |Device B",
"Min": 16,
"Avg": 16,
"Max": 16,
"date": "2025-06-05 21:10:54 +0300"
}
],
"heartRate": {
"avg": {
"units": "count/min",
"qty": 47.271142352144859
},
"max": {
"units": "count/min",
"qty": 199
},
"min": {
"units": "count/min",
"qty": 68
}
},
"humidity": {
"units": "%",
"qty": 49
},
"duration": 11.104378534563007,
"walkingAndRunningDistance": [
{
"date": "2025-06-05 21:07:25 +0300",
"source": "Device A  ",
"units": "km",
"qty": 0.051178653222924688
},
{
"units": "km",
"source": "Device A  ",
"date": "2025-06-05 21:08:25 +0300",
"qty": 0.0022744856624713684
}
],
"metadata": {},
"start": "2025-06-05 21:07:25 +0300"
}
]
}
}
+18 -5
View File
@@ -83,12 +83,21 @@ func TestReplayЖивогоАрхива(t *testing.T) {
t.Fatal("ни одна доставка не свернулась") t.Fatal("ни одна доставка не свернулась")
} }
// Половина живого потока не несёт metrics вовсе (находка 50): такие // Половина живого потока не несёт metrics вовсе (находка 50): это
// доставки обязаны быть отличимы от разобранных целиком, иначе ретеншен // тренировки и состояние разума. С тех пор как обе секции покрыты,
// срежет тела, которые для stateOfMind единственный источник. // частично разобранных доставок в архиве нет — и вместо них проверяется то,
if first.Partial == 0 { // ради чего покрытие делалось: сущности доехали до витрины.
t.Error("ни одной частично разобранной доставки — перечисление непокрытых секций не работает") //
// Проверяется свойство, а не число: корпус растёт с каждой доставкой, а
// прогон живого архива в гейт не входит, так что протухшая константа
// покраснела бы молча.
if first.Workouts == 0 {
t.Error("тренировок в витрине нет — секция workouts не разбирается")
} }
if first.Records == 0 {
t.Error("записей в витрине нет — секция stateOfMind не разбирается")
}
t.Logf("тренировок %d, записей %d", first.Workouts, first.Records)
// Повторное проигрывание того же журнала даёт то же состояние: свёртка // Повторное проигрывание того же журнала даёт то же состояние: свёртка
// детерминирована, и пересборка даёт то же, что живой приём. // детерминирована, и пересборка даёт то же, что живой приём.
@@ -101,6 +110,10 @@ func TestReplayЖивогоАрхива(t *testing.T) {
if second.Buckets != first.Buckets { if second.Buckets != first.Buckets {
t.Errorf("повторное проигрывание изменило число объектов: %d → %d", first.Buckets, second.Buckets) t.Errorf("повторное проигрывание изменило число объектов: %d → %d", first.Buckets, second.Buckets)
} }
if second.Workouts != first.Workouts || second.Records != first.Records {
t.Errorf("повторное проигрывание изменило число сущностей: %d/%d → %d/%d",
first.Workouts, first.Records, second.Workouts, second.Records)
}
if second.Fingerprint != first.Fingerprint { if second.Fingerprint != first.Fingerprint {
t.Errorf("повторное проигрывание изменило содержимое объектов:\n %s\n %s", t.Errorf("повторное проигрывание изменило содержимое объектов:\n %s\n %s",
first.Fingerprint, second.Fingerprint) first.Fingerprint, second.Fingerprint)
+16 -1
View File
@@ -76,6 +76,11 @@ type Report struct {
Outcome Outcome
Buckets int64 Buckets int64
// Workouts и Records — остальные единицы хранения витрины. Считаются рядом
// с объектами потому, что отпечаток отвечает «да/нет» за витрину целиком, а
// решение о подмене базы необратимо и требует направления расхождения.
Workouts int64
Records int64
Fingerprint string Fingerprint string
// Canceled — проигрывание прервано отменой, а не дошло до конца. // Canceled — проигрывание прервано отменой, а не дошло до конца.
@@ -171,6 +176,14 @@ func Run(ctx context.Context, o Options) (Report, error) {
if err != nil { if err != nil {
return stopOr(rep, err) return stopOr(rep, err)
} }
rep.Workouts, err = o.Target.CountWorkouts(ctx)
if err != nil {
return stopOr(rep, err)
}
rep.Records, err = o.Target.CountRecords(ctx)
if err != nil {
return stopOr(rep, err)
}
rep.Fingerprint, err = o.Target.Fingerprint(ctx) rep.Fingerprint, err = o.Target.Fingerprint(ctx)
if err != nil { if err != nil {
return stopOr(rep, err) return stopOr(rep, err)
@@ -190,7 +203,9 @@ func Run(ctx context.Context, o Options) (Report, error) {
"failed_other", rep.FailedOther, "failed_other", rep.FailedOther,
"partial", rep.Partial, "partial", rep.Partial,
"incomparable", rep.Incomparable, "incomparable", rep.Incomparable,
"buckets", rep.Buckets) "buckets", rep.Buckets,
"workouts", rep.Workouts,
"records", rep.Records)
return rep, nil return rep, nil
} }
+173 -24
View File
@@ -72,6 +72,21 @@ type MergeStats struct {
Collisions []Collision Collisions []Collision
// IncomparableAt — то же для несравнимых наборов. // IncomparableAt — то же для несравнимых наборов.
IncomparableAt []Collision IncomparableAt []Collision
// Workouts и Records — сколько сущностей пришло с доставкой.
Workouts int
Records int
// WorkoutsWritten и RecordsWritten — сколько из них действительно легло в
// витрину. Разница с пришедшими — работа хеша-детектора: тренировка
// переприсылается каждой доставкой, пока не доедет маршрут.
WorkoutsWritten int
RecordsWritten int
// EntitiesHeld — приехавшие версии, отклонённые как теряющие содержание
// сохранённой (включая несравнимые наборы). Это и есть плата за отказ
// объединять поля: событие считается, а не предотвращается молча.
EntitiesHeld int
// HeldAt — координаты первых таких сущностей, для записи в лог.
HeldAt []EntityRef
} }
// Collision — координаты объекта, где столкновение разрешилось перезаписью // Collision — координаты объекта, где столкновение разрешилось перезаписью
@@ -102,35 +117,58 @@ func clipMetric(metric string) string {
return metric[:maxMetricInLog] + "…" return metric[:maxMetricInLog] + "…"
} }
// MergePoints раскладывает точки по часовым объектам и сливает их с // Merge раскладывает всё, что дала доставка, по витрине: точки — по часовым
// сохранёнными. // объектам, сущности — по своим таблицам.
// //
// Час берётся по НАЧАЛУ точки: интервал пересекает границы часов, и любой // Час берётся по НАЧАЛУ точки: интервал пересекает границы часов, и любой
// другой выбор сделал бы принадлежность объекту зависящей от длительности. // другой выбор сделал бы принадлежность объекту зависящей от длительности.
// Вся доставка сворачивается ОДНОЙ транзакцией, а не транзакцией на объект. // Вся доставка сворачивается ОДНОЙ транзакцией, а не транзакцией на объект и
// не отдельной транзакцией на сущности.
// //
// Транзакция на объект давала недетерминированное частичное состояние: обход // Транзакция на объект давала недетерминированное частичное состояние: обход
// карты групп рандомизирован, и при отказе посреди доставки набор уже // карты групп рандомизирован, и при отказе посреди доставки набор уже
// закоммиченных объектов каждый раз другой (измерено: восемь прогонов одной // закоммиченных объектов каждый раз другой (измерено: восемь прогонов одной
// доставки — семь разных состояний). Это ломает инвариант «состояние // доставки — семь разных состояний). Это ломает инвариант «состояние
// пересобираемо»: пересборка из архива давала бы не то, что живой приём, а // пересобираемо»: пересборка из архива давала бы не то, что живой приём, а
// хеш-детектор после расхождения переписывал бы «неизменившееся». // хеш-детектор после расхождения переписывал бы «неизменившееся». Наблюдение
// «секции не смешиваются в одной доставке» собрано за двое суток и основанием
// для второй транзакции не является.
// //
// Заодно снимается стоимость: отдельный коммит на объект стоил около 0.7 мс, // Заодно снимается стоимость: отдельный коммит на объект стоил около 0.7 мс,
// то есть 8.5 мс на килобайт тела. // то есть 8.5 мс на килобайт тела.
func (s *Store) MergePoints(ctx context.Context, in []IncomingPoint, deliveryID string) (MergeStats, error) { func (s *Store) Merge(ctx context.Context, in Incoming, from DeliveryRef) (MergeStats, error) {
groups := groupByHour(in) groups := groupByHour(in.Points)
keys := sortedKeys(groups) keys := sortedKeys(groups)
// Хеш и каноническая форма сущности считаются ОДИН раз на доставку, до
// входа в транзакцию: канонизация материализует значение целиком, а
// транзакция повторяется до пяти раз при занятости базы — внутри неё пик
// кучи умножился бы на число попыток.
workouts, err := prepareEntities(in.Workouts, from)
if err != nil {
return MergeStats{}, err
}
records, err := prepareEntities(in.Records, from)
if err != nil {
return MergeStats{}, err
}
workouts, workoutsHeld, workoutsHeldAt := dedupeEntities(workouts)
records, recordsHeld, recordsHeldAt := dedupeEntities(records)
var stats MergeStats var stats MergeStats
err := s.inTx(ctx, func(tx *sql.Tx) error { err = s.inTx(ctx, func(tx *sql.Tx) error {
// Счётчики обнуляются на каждой попытке: повтор транзакции начинает // Счётчики обнуляются на каждой попытке: повтор транзакции начинает
// слияние заново, и накопленное от прошлой попытки посчиталось бы дважды. // слияние заново, и накопленное от прошлой попытки посчиталось бы дважды.
stats = MergeStats{} stats = MergeStats{
Workouts: len(in.Workouts),
Records: len(in.Records),
EntitiesHeld: workoutsHeld + recordsHeld,
HeldAt: clipRefs(append(append([]EntityRef{}, workoutsHeldAt...), recordsHeldAt...)),
}
for _, key := range keys { for _, key := range keys {
group := groups[key] group := groups[key]
res, err := mergeBucket(ctx, tx, key, group, deliveryID) res, err := mergeBucket(ctx, tx, key, group, from.ID)
if err != nil { if err != nil {
return err return err
} }
@@ -158,6 +196,23 @@ func (s *Store) MergePoints(ctx context.Context, in []IncomingPoint, deliveryID
stats.IncomparableAt = append(stats.IncomparableAt, coord) stats.IncomparableAt = append(stats.IncomparableAt, coord)
} }
} }
now := Now()
written, held, heldAt, err := mergeEntities(ctx, tx, workoutTable, workouts, now)
if err != nil {
return err
}
stats.WorkoutsWritten = written
stats.EntitiesHeld += held
stats.HeldAt = clipRefs(append(stats.HeldAt, heldAt...))
written, held, heldAt, err = mergeEntities(ctx, tx, recordTable, records, now)
if err != nil {
return err
}
stats.RecordsWritten = written
stats.EntitiesHeld += held
stats.HeldAt = clipRefs(append(stats.HeldAt, heldAt...))
return nil return nil
}) })
if err != nil { if err != nil {
@@ -591,27 +646,44 @@ func encodePayload(points []Point) ([]byte, error) {
return nil, fmt.Errorf("сериализация точек: %w", err) return nil, fmt.Errorf("сериализация точек: %w", err)
} }
return gzipBytes(raw.Bytes())
}
// gzipBytes и gunzipBytes — единственное кодирование содержимого витрины,
// общее для часового объекта и для сущности с собственным `id`. Второй кадр
// упаковки разошёлся бы с первым при первой же правке — например, забытым
// Close, который оставляет усечённый блоб.
func gzipBytes(raw []byte) ([]byte, error) {
var buf bytes.Buffer var buf bytes.Buffer
gz := gzip.NewWriter(&buf) gz := gzip.NewWriter(&buf)
if _, err := gz.Write(raw.Bytes()); err != nil { if _, err := gz.Write(raw); err != nil {
return nil, fmt.Errorf("сжатие точек: %w", err) return nil, fmt.Errorf("сжатие содержимого: %w", err)
} }
// Close дописывает хвост gzip; без него блоб читается лишь частично.
if err := gz.Close(); err != nil { if err := gz.Close(); err != nil {
return nil, fmt.Errorf("закрытие gzip: %w", err) return nil, fmt.Errorf("закрытие gzip: %w", err)
} }
return buf.Bytes(), nil return buf.Bytes(), nil
} }
func decodePayload(payload []byte) ([]Point, error) { func gunzipBytes(payload []byte) ([]byte, error) {
gz, err := gzip.NewReader(bytes.NewReader(payload)) gz, err := gzip.NewReader(bytes.NewReader(payload))
if err != nil { if err != nil {
return nil, fmt.Errorf("распаковка точек: %w", err) return nil, fmt.Errorf("распаковка содержимого: %w", err)
} }
defer func() { _ = gz.Close() }() defer func() { _ = gz.Close() }()
raw, err := io.ReadAll(gz) raw, err := io.ReadAll(gz)
if err != nil { if err != nil {
return nil, fmt.Errorf("чтение точек: %w", err) return nil, fmt.Errorf("чтение содержимого: %w", err)
}
return raw, nil
}
func decodePayload(payload []byte) ([]Point, error) {
raw, err := gunzipBytes(payload)
if err != nil {
return nil, err
} }
var sp []storedPoint var sp []storedPoint
@@ -700,7 +772,7 @@ func (s *Store) CountBuckets(ctx context.Context) (int64, error) {
} }
// Fingerprint возвращает отпечаток содержимого витрины: SHA-256 по координатам // Fingerprint возвращает отпечаток содержимого витрины: SHA-256 по координатам
// и хешам всех объектов в детерминированном порядке. // и хешам всех её сущностей в детерминированном порядке.
// //
// Нужен проверке сходимости на живом архиве. Число объектов и число точек к // Нужен проверке сходимости на живом архиве. Число объектов и число точек к
// правилу разрешения столкновений нечувствительны: на координате всегда лежит // правилу разрешения столкновений нечувствительны: на координате всегда лежит
@@ -708,25 +780,58 @@ func (s *Store) CountBuckets(ctx context.Context) (int64, error) {
// Значит «объектов столько же» совпадёт и при заведомо сломанном правиле, а // Значит «объектов столько же» совпадёт и при заведомо сломанном правиле, а
// отпечаток — нет. // отпечаток — нет.
// //
// Значений точек он не раскрывает: содержимое участвует только своим хешем. // Покрывает ВСЕ единицы хранения — часовые объекты, тренировки и записи.
// Отпечаток одних объектов давал бы «состояние сошлось» при разъехавшихся
// тренировках, то есть ломался бы молча тем самым изменением, которое добавило
// данные.
//
// Все разделы читаются ОДНИМ снимком базы: отпечаток рабочей витрины снимается
// под живым приёмом, и запросы вне общей транзакции дали бы смесь «объекты до»
// и «тренировки после» — ложное расхождение у единственного оракула.
//
// Значений он не раскрывает: содержимое участвует только своим хешем.
func (s *Store) Fingerprint(ctx context.Context) (string, error) { func (s *Store) Fingerprint(ctx context.Context) (string, error) {
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
if err != nil {
return "", fmt.Errorf("begin read tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
h := sha256.New()
if err := fingerprintBuckets(ctx, tx, h); err != nil {
return "", err
}
if err := fingerprintEntities(ctx, tx, h); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// Признак раздела впереди строки: без него строка одного раздела может совпасть
// со строкой другого, и два разных состояния витрины дали бы один отпечаток.
const (
fpBucket = "b"
fpWorkout = "w"
fpRecord = "r"
)
func fingerprintBuckets(ctx context.Context, tx *sql.Tx, h io.Writer) error {
const q = ` const q = `
SELECT metric, layer, hour_utc, content_hash, points, units, sealed FROM bucket SELECT metric, layer, hour_utc, content_hash, points, units, sealed FROM bucket
ORDER BY metric, layer, hour_utc` ORDER BY metric, layer, hour_utc`
rows, err := s.db.QueryContext(ctx, q) rows, err := tx.QueryContext(ctx, q)
if err != nil { if err != nil {
return "", fmt.Errorf("select buckets: %w", err) return fmt.Errorf("select buckets: %w", err)
} }
defer func() { _ = rows.Close() }() defer func() { _ = rows.Close() }()
h := sha256.New()
for rows.Next() { for rows.Next() {
var metric, layer, hour, hash, units string var metric, layer, hour, hash, units string
var points int var points int
var sealed bool var sealed bool
if err := rows.Scan(&metric, &layer, &hour, &hash, &points, &units, &sealed); err != nil { if err := rows.Scan(&metric, &layer, &hour, &hash, &points, &units, &sealed); err != nil {
return "", fmt.Errorf("scan bucket: %w", err) return fmt.Errorf("scan bucket: %w", err)
} }
// Поля переменной длины идут с длиной впереди: разделитель, который // Поля переменной длины идут с длиной впереди: разделитель, который
// может встретиться ВНУТРИ поля, даёт одну строку для разных состояний, // может встретиться ВНУТРИ поля, даёт одну строку для разных состояний,
@@ -734,13 +839,57 @@ func (s *Store) Fingerprint(ctx context.Context) (string, error) {
// здесь значит получить «состояние совпало» при разошедшемся состоянии — // здесь значит получить «состояние совпало» при разошедшемся состоянии —
// то есть сломать молча ровно тот оракул, ради которого отпечаток и // то есть сломать молча ровно тот оракул, ради которого отпечаток и
// заведён. Тот же приём в canon.HashAll и по той же причине. // заведён. Тот же приём в canon.HashAll и по той же причине.
fmt.Fprintf(h, "%d:%s|%d:%s|%s|%s|%d|%d:%s|%t\n", fmt.Fprintf(h, "%s|%d:%s|%d:%s|%s|%s|%d|%d:%s|%t\n",
len(metric), metric, len(layer), layer, hour, hash, points, len(units), units, sealed) fpBucket, len(metric), metric, len(layer), layer, hour, hash, points,
len(units), units, sealed)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return "", fmt.Errorf("select buckets: %w", err) return fmt.Errorf("select buckets: %w", err)
} }
return hex.EncodeToString(h.Sum(nil)), nil return nil
}
func fingerprintEntities(ctx context.Context, tx *sql.Tx, h io.Writer) error {
queries := []struct {
tag string
sql string
}{
// Род и идентификатор идут ОТДЕЛЬНЫМИ полями, каждое со своей длиной, а
// не склейкой `kind || '/' || id`: склейка выполняется до взятия длины,
// и пара (`a`, `b/c`) даёт ту же строку, что (`a/b`, `c`). Отпечаток —
// единственный оракул сходимости, по нему принимается необратимое
// решение о подмене базы; два разных состояния витрины не имеют права
// дать один отпечаток. У тренировки род один и в строку не идёт.
{fpWorkout, `SELECT '', id, start_utc, content_hash FROM workout ORDER BY id`},
{fpRecord, `SELECT kind, id, ts_utc, content_hash FROM record ORDER BY kind, id`},
}
for _, q := range queries {
if err := fingerprintRows(ctx, tx, h, q.tag, q.sql); err != nil {
return err
}
}
return nil
}
func fingerprintRows(ctx context.Context, tx *sql.Tx, h io.Writer, tag, query string) error {
rows, err := tx.QueryContext(ctx, query)
if err != nil {
return fmt.Errorf("select entities: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var kind, id, ts, hash string
if err := rows.Scan(&kind, &id, &ts, &hash); err != nil {
return fmt.Errorf("scan entity: %w", err)
}
fmt.Fprintf(h, "%s|%d:%s|%d:%s|%s|%s\n", tag, len(kind), kind, len(id), id, ts, hash)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("select entities: %w", err)
}
return nil
} }
// Bucket читает объект по координатам. Нужен тестам и будущему Read API. // Bucket читает объект по координатам. Нужен тестам и будущему Read API.
+67 -56
View File
@@ -51,7 +51,7 @@ func point(t *testing.T, metric, layer, start, end, raw string) store.IncomingPo
} }
} }
func TestMergePointsКладётЧасОднимОбъектом(t *testing.T) { func TestMergeКладётЧасОднимОбъектом(t *testing.T) {
t.Parallel() t.Parallel()
st := open(t) st := open(t)
@@ -63,7 +63,7 @@ func TestMergePointsКладётЧасОднимОбъектом(t *testing.T) {
point(t, "step_count", "minute", "2025-06-05T11:00:00Z", "2025-06-05T11:00:00Z", `{"qty":3}`), point(t, "step_count", "minute", "2025-06-05T11:00:00Z", "2025-06-05T11:00:00Z", `{"qty":3}`),
} }
stats, err := st.MergePoints(ctx, in, "delivery-1") stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "delivery-1"})
if err != nil { if err != nil {
t.Fatalf("слияние: %v", err) t.Fatalf("слияние: %v", err)
} }
@@ -91,7 +91,7 @@ func TestMergePointsКладётЧасОднимОбъектом(t *testing.T) {
// Дозапись в существующий час: объект перечитывается, точки сливаются, ранее // Дозапись в существующий час: объект перечитывается, точки сливаются, ранее
// сохранённые остаются. Точки из объекта не удаляются никогда. // сохранённые остаются. Точки из объекта не удаляются никогда.
func TestMergePointsДозаписьНеТеряетСохранённое(t *testing.T) { func TestMergeДозаписьНеТеряетСохранённое(t *testing.T) {
t.Parallel() t.Parallel()
st := open(t) st := open(t)
@@ -104,10 +104,10 @@ func TestMergePointsДозаписьНеТеряетСохранённое(t *te
point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`), point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`),
} }
if _, err := st.MergePoints(ctx, first, "d1"); err != nil { if _, err := st.Merge(ctx, store.Incoming{Points: first}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err) t.Fatalf("первое слияние: %v", err)
} }
if _, err := st.MergePoints(ctx, second, "d2"); err != nil { if _, err := st.Merge(ctx, store.Incoming{Points: second}, store.DeliveryRef{ID: "d2"}); err != nil {
t.Fatalf("второе слияние: %v", err) t.Fatalf("второе слияние: %v", err)
} }
@@ -125,7 +125,7 @@ func TestMergePointsДозаписьНеТеряетСохранённое(t *te
// Хеш — детектор изменений: повтор того же часа не пишет в базу. Именно это // Хеш — детектор изменений: повтор того же часа не пишет в базу. Именно это
// делает широкие проходы синхронизации дешёвыми. // делает широкие проходы синхронизации дешёвыми.
func TestMergePointsПовторНеПишет(t *testing.T) { func TestMergeПовторНеПишет(t *testing.T) {
t.Parallel() t.Parallel()
st := open(t) st := open(t)
@@ -136,7 +136,7 @@ func TestMergePointsПовторНеПишет(t *testing.T) {
point(t, "step_count", "minute", "2025-06-05T10:01:00Z", "2025-06-05T10:01:00Z", `{"qty":2,"source":"Device A"}`), point(t, "step_count", "minute", "2025-06-05T10:01:00Z", "2025-06-05T10:01:00Z", `{"qty":2,"source":"Device A"}`),
} }
if _, err := st.MergePoints(ctx, in, "d1"); err != nil { if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err) t.Fatalf("первое слияние: %v", err)
} }
before, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z")) before, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
@@ -144,7 +144,7 @@ func TestMergePointsПовторНеПишет(t *testing.T) {
t.Fatalf("чтение: %v", err) t.Fatalf("чтение: %v", err)
} }
stats, err := st.MergePoints(ctx, in, "d2") stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d2"})
if err != nil { if err != nil {
t.Fatalf("повторное слияние: %v", err) t.Fatalf("повторное слияние: %v", err)
} }
@@ -172,7 +172,7 @@ func TestMergePointsПовторНеПишет(t *testing.T) {
// Порядок точек внутри доставки нестабилен (находка 2), и идентичность не // Порядок точек внутри доставки нестабилен (находка 2), и идентичность не
// имеет права от него зависеть. // имеет права от него зависеть.
func TestMergePointsИдемпотентенКПорядкуТочек(t *testing.T) { func TestMergeИдемпотентенКПорядкуТочек(t *testing.T) {
t.Parallel() t.Parallel()
st := open(t) st := open(t)
@@ -185,7 +185,7 @@ func TestMergePointsИдемпотентенКПорядкуТочек(t *testin
} }
reversed := []store.IncomingPoint{in[2], in[1], in[0]} reversed := []store.IncomingPoint{in[2], in[1], in[0]}
if _, err := st.MergePoints(ctx, in, "d1"); err != nil { if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err) t.Fatalf("первое слияние: %v", err)
} }
first, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z")) first, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
@@ -193,7 +193,7 @@ func TestMergePointsИдемпотентенКПорядкуТочек(t *testin
t.Fatalf("чтение: %v", err) t.Fatalf("чтение: %v", err)
} }
stats, err := st.MergePoints(ctx, reversed, "d2") stats, err := st.Merge(ctx, store.Incoming{Points: reversed}, store.DeliveryRef{ID: "d2"})
if err != nil { if err != nil {
t.Fatalf("слияние в обратном порядке: %v", err) t.Fatalf("слияние в обратном порядке: %v", err)
} }
@@ -213,7 +213,7 @@ func TestMergePointsИдемпотентенКПорядкуТочек(t *testin
// Ключ по метке схлопнул бы эти три записи в одну. Живьём такое встречается в // Ключ по метке схлопнул бы эти три записи в одну. Живьём такое встречается в
// 22 доставках из 94 (находка 47), причём внутри одной доставки — там тай-брейк // 22 доставках из 94 (находка 47), причём внутри одной доставки — там тай-брейк
// по времени приёма неприменим в принципе. // по времени приёма неприменим в принципе.
func TestMergePointsЗаписиСОднойМеткойНеСхлопываются(t *testing.T) { func TestMergeЗаписиСОднойМеткойНеСхлопываются(t *testing.T) {
t.Parallel() t.Parallel()
st := open(t) st := open(t)
@@ -225,7 +225,7 @@ func TestMergePointsЗаписиСОднойМеткойНеСхлопывают
point(t, "sleep_analysis", "minute", "2025-06-05T19:04:00Z", "2025-06-06T04:51:00Z", `{"qty":9.78,"value":"В кровати"}`), point(t, "sleep_analysis", "minute", "2025-06-05T19:04:00Z", "2025-06-06T04:51:00Z", `{"qty":9.78,"value":"В кровати"}`),
} }
if _, err := st.MergePoints(ctx, in, "d1"); err != nil { if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("слияние: %v", err) t.Fatalf("слияние: %v", err)
} }
@@ -239,7 +239,7 @@ func TestMergePointsЗаписиСОднойМеткойНеСхлопывают
// Повтор той же тройки не задваивает: координата включает интервал, и он // Повтор той же тройки не задваивает: координата включает интервал, и он
// совпадает. // совпадает.
stats, err := st.MergePoints(ctx, in, "d2") stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d2"})
if err != nil { if err != nil {
t.Fatalf("повтор: %v", err) t.Fatalf("повтор: %v", err)
} }
@@ -250,7 +250,7 @@ func TestMergePointsЗаписиСОднойМеткойНеСхлопывают
// Эпизод, пересекающий границу часа, ложится в объект по НАЧАЛУ: любой другой // Эпизод, пересекающий границу часа, ложится в объект по НАЧАЛУ: любой другой
// выбор сделал бы принадлежность объекту зависящей от длительности. // выбор сделал бы принадлежность объекту зависящей от длительности.
func TestMergePointsЧасПоНачалуИнтервала(t *testing.T) { func TestMergeЧасПоНачалуИнтервала(t *testing.T) {
t.Parallel() t.Parallel()
st := open(t) st := open(t)
@@ -259,7 +259,7 @@ func TestMergePointsЧасПоНачалуИнтервала(t *testing.T) {
in := []store.IncomingPoint{ in := []store.IncomingPoint{
point(t, "sleep_analysis", "minute", "2025-06-05T19:04:00Z", "2025-06-06T04:51:00Z", `{"qty":9.78}`), point(t, "sleep_analysis", "minute", "2025-06-05T19:04:00Z", "2025-06-06T04:51:00Z", `{"qty":9.78}`),
} }
if _, err := st.MergePoints(ctx, in, "d1"); err != nil { if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("слияние: %v", err) t.Fatalf("слияние: %v", err)
} }
@@ -274,7 +274,7 @@ func TestMergePointsЧасПоНачалуИнтервала(t *testing.T) {
// Правило разрешения столкновений: выигрывает более полная точка, а не // Правило разрешения столкновений: выигрывает более полная точка, а не
// последняя пришедшая. Иначе бедная доставка стирает у богатой поля, которых // последняя пришедшая. Иначе бедная доставка стирает у богатой поля, которых
// сама не несёт. // сама не несёт.
func TestMergePointsБеднаяТочкаНеСтираетБогатую(t *testing.T) { func TestMergeБеднаяТочкаНеСтираетБогатую(t *testing.T) {
t.Parallel() t.Parallel()
st := open(t) st := open(t)
@@ -285,10 +285,10 @@ func TestMergePointsБеднаяТочкаНеСтираетБогатую(t *te
poor := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", poor := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"Avg":60,"Min":55,"Max":70}`) `{"Avg":60,"Min":55,"Max":70}`)
if _, err := st.MergePoints(ctx, []store.IncomingPoint{rich}, "d1"); err != nil { if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{rich}}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err) t.Fatalf("первое слияние: %v", err)
} }
stats, err := st.MergePoints(ctx, []store.IncomingPoint{poor}, "d2") stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{poor}}, store.DeliveryRef{ID: "d2"})
if err != nil { if err != nil {
t.Fatalf("второе слияние: %v", err) t.Fatalf("второе слияние: %v", err)
} }
@@ -311,7 +311,7 @@ func TestMergePointsБеднаяТочкаНеСтираетБогатую(t *te
// Полнота — множество ключей, а не их число. Счётчик значащих полей давал // Полнота — множество ключей, а не их число. Счётчик значащих полей давал
// сохранённой точке 5 против 2 и стирал настоящее измерение безвозвратно: // сохранённой точке 5 против 2 и стирал настоящее измерение безвозвратно:
// восстановить его можно было бы только из сырого архива, пока он жив. // восстановить его можно было бы только из сырого архива, пока он жив.
func TestMergePointsПоляБезСодержанияНеСтираютИзмерение(t *testing.T) { func TestMergeПоляБезСодержанияНеСтираютИзмерение(t *testing.T) {
t.Parallel() t.Parallel()
st := open(t) st := open(t)
@@ -322,10 +322,10 @@ func TestMergePointsПоляБезСодержанияНеСтираютИзме
measured := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", measured := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"date":"2026-07-31 12:00:00 +0300","qty":123.4}`) `{"date":"2026-07-31 12:00:00 +0300","qty":123.4}`)
if _, err := st.MergePoints(ctx, []store.IncomingPoint{hollow}, "d1"); err != nil { if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{hollow}}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err) t.Fatalf("первое слияние: %v", err)
} }
stats, err := st.MergePoints(ctx, []store.IncomingPoint{measured}, "d2") stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{measured}}, store.DeliveryRef{ID: "d2"})
if err != nil { if err != nil {
t.Fatalf("второе слияние: %v", err) t.Fatalf("второе слияние: %v", err)
} }
@@ -345,7 +345,7 @@ func TestMergePointsПоляБезСодержанияНеСтираютИзме
// Поле с нулевым значением содержания не несёт, но и теряться не должно: при // Поле с нулевым значением содержания не несёт, но и теряться не должно: при
// равном множестве содержательных ключей выигрывает точка со всеми ключами. // равном множестве содержательных ключей выигрывает точка со всеми ключами.
func TestMergePointsРавноеСодержаниеНеТеряетПоля(t *testing.T) { func TestMergeРавноеСодержаниеНеТеряетПоля(t *testing.T) {
t.Parallel() t.Parallel()
st := open(t) st := open(t)
@@ -356,10 +356,10 @@ func TestMergePointsРавноеСодержаниеНеТеряетПоля(t *
narrow := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", narrow := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"date":"d","qty":12}`) `{"date":"d","qty":12}`)
if _, err := st.MergePoints(ctx, []store.IncomingPoint{wide}, "d1"); err != nil { if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{wide}}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err) t.Fatalf("первое слияние: %v", err)
} }
if _, err := st.MergePoints(ctx, []store.IncomingPoint{narrow}, "d2"); err != nil { if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{narrow}}, store.DeliveryRef{ID: "d2"}); err != nil {
t.Fatalf("второе слияние: %v", err) t.Fatalf("второе слияние: %v", err)
} }
@@ -375,7 +375,7 @@ func TestMergePointsРавноеСодержаниеНеТеряетПоля(t *
// Несравнимые наборы полей на живом потоке не встретились ни разу (0 из 2 897 // Несравнимые наборы полей на живом потоке не встретились ни разу (0 из 2 897
// столкновений), поэтому объединение полей не реализовано. Взамен — наблюдение: // столкновений), поэтому объединение полей не реализовано. Взамен — наблюдение:
// счётчик и координаты объекта, по которым событие можно будет разобрать. // счётчик и координаты объекта, по которым событие можно будет разобрать.
func TestMergePointsНесравнимыеНаборыСчитаются(t *testing.T) { func TestMergeНесравнимыеНаборыСчитаются(t *testing.T) {
t.Parallel() t.Parallel()
st := open(t) st := open(t)
@@ -386,10 +386,10 @@ func TestMergePointsНесравнимыеНаборыСчитаются(t *test
b := point(t, "blood_glucose", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", b := point(t, "blood_glucose", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"mealTime":"До еды"}`) `{"mealTime":"До еды"}`)
if _, err := st.MergePoints(ctx, []store.IncomingPoint{a}, "d1"); err != nil { if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{a}}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err) t.Fatalf("первое слияние: %v", err)
} }
stats, err := st.MergePoints(ctx, []store.IncomingPoint{b}, "d2") stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{b}}, store.DeliveryRef{ID: "d2"})
if err != nil { if err != nil {
t.Fatalf("второе слияние: %v", err) t.Fatalf("второе слияние: %v", err)
} }
@@ -420,7 +420,7 @@ func TestMergePointsНесравнимыеНаборыСчитаются(t *test
// Список координат упирается в потолок, счётчик — нет: обрезанный список // Список координат упирается в потолок, счётчик — нет: обрезанный список
// остаётся зацепкой для разбора, а масштаб события считает счётчик. // остаётся зацепкой для разбора, а масштаб события считает счётчик.
func TestMergePointsСчётчикРастётПослеПотолкаКоординат(t *testing.T) { func TestMergeСчётчикРастётПослеПотолкаКоординат(t *testing.T) {
t.Parallel() t.Parallel()
st := open(t) st := open(t)
@@ -434,10 +434,10 @@ func TestMergePointsСчётчикРастётПослеПотолкаКоорд
second = append(second, point(t, "blood_glucose", "minute", at, at, `{"mealTime":"До еды"}`)) second = append(second, point(t, "blood_glucose", "minute", at, at, `{"mealTime":"До еды"}`))
} }
if _, err := st.MergePoints(ctx, first, "d1"); err != nil { if _, err := st.Merge(ctx, store.Incoming{Points: first}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err) t.Fatalf("первое слияние: %v", err)
} }
stats, err := st.MergePoints(ctx, second, "d2") stats, err := st.Merge(ctx, store.Incoming{Points: second}, store.DeliveryRef{ID: "d2"})
if err != nil { if err != nil {
t.Fatalf("второе слияние: %v", err) t.Fatalf("второе слияние: %v", err)
} }
@@ -453,7 +453,7 @@ func TestMergePointsСчётчикРастётПослеПотолкаКоорд
// source нестабилен: то же измерение приезжает то с одним именем устройства, // source нестабилен: то же измерение приезжает то с одним именем устройства,
// то с другим. Он не входит в ключ и не считается полнотой. // то с другим. Он не входит в ключ и не считается полнотой.
func TestMergePointsСменаИсточникаНеСоздаётВторуюТочку(t *testing.T) { func TestMergeСменаИсточникаНеСоздаётВторуюТочку(t *testing.T) {
t.Parallel() t.Parallel()
st := open(t) st := open(t)
@@ -464,10 +464,10 @@ func TestMergePointsСменаИсточникаНеСоздаётВторуюТ
b := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", b := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"qty":1,"source":"Apple Watch Ultra 3"}`) `{"qty":1,"source":"Apple Watch Ultra 3"}`)
if _, err := st.MergePoints(ctx, []store.IncomingPoint{a}, "d1"); err != nil { if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{a}}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err) t.Fatalf("первое слияние: %v", err)
} }
if _, err := st.MergePoints(ctx, []store.IncomingPoint{b}, "d2"); err != nil { if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{b}}, store.DeliveryRef{ID: "d2"}); err != nil {
t.Fatalf("второе слияние: %v", err) t.Fatalf("второе слияние: %v", err)
} }
@@ -483,7 +483,7 @@ func TestMergePointsСменаИсточникаНеСоздаётВторуюТ
// Исход столкновения точек равной полноты обязан зависеть только от значений: // Исход столкновения точек равной полноты обязан зависеть только от значений:
// свёртка по журналу должна давать то же состояние, что приём в реальном // свёртка по журналу должна давать то же состояние, что приём в реальном
// времени, а внутри одной доставки время приёма общее. // времени, а внутри одной доставки время приёма общее.
func TestMergePointsРавнаяПолнотаРазрешаетсяДетерминированно(t *testing.T) { func TestMergeРавнаяПолнотаРазрешаетсяДетерминированно(t *testing.T) {
t.Parallel() t.Parallel()
ctx := context.Background() ctx := context.Background()
@@ -494,7 +494,7 @@ func TestMergePointsРавнаяПолнотаРазрешаетсяДетерм
winner := func(order []store.IncomingPoint) string { winner := func(order []store.IncomingPoint) string {
st := open(t) st := open(t)
for _, p := range order { for _, p := range order {
if _, err := st.MergePoints(ctx, []store.IncomingPoint{p}, "d"); err != nil { if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p}}, store.DeliveryRef{ID: "d"}); err != nil {
t.Fatalf("слияние: %v", err) t.Fatalf("слияние: %v", err)
} }
} }
@@ -515,7 +515,7 @@ func TestMergePointsРавнаяПолнотаРазрешаетсяДетерм
// Содержимое точки хранится исходными байтами: пересборка повторной // Содержимое точки хранится исходными байтами: пересборка повторной
// сериализацией теряет литерал, и потеря не видна тестам, сравнивающим // сериализацией теряет литерал, и потеря не видна тестам, сравнивающим
// разобранное с разобранным. // разобранное с разобранным.
func TestMergePointsХранитТочкуДословно(t *testing.T) { func TestMergeХранитТочкуДословно(t *testing.T) {
t.Parallel() t.Parallel()
st := open(t) st := open(t)
@@ -525,7 +525,7 @@ func TestMergePointsХранитТочкуДословно(t *testing.T) {
in := []store.IncomingPoint{ in := []store.IncomingPoint{
point(t, "unknown", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", raw), point(t, "unknown", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", raw),
} }
if _, err := st.MergePoints(ctx, in, "d1"); err != nil { if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("слияние: %v", err) t.Fatalf("слияние: %v", err)
} }
@@ -541,7 +541,7 @@ func TestMergePointsХранитТочкуДословно(t *testing.T) {
// Тело доставки доходит до 42 МиБ, приходят они непрерывно и внахлёст. // Тело доставки доходит до 42 МиБ, приходят они непрерывно и внахлёст.
// Конкурентное слияние того же часа не имеет права терять точки: между // Конкурентное слияние того же часа не имеет права терять точки: между
// чтением и записью может вклиниться другая доставка. // чтением и записью может вклиниться другая доставка.
func TestMergePointsКонкурентноеСлияниеНеТеряетТочки(t *testing.T) { func TestMergeКонкурентноеСлияниеНеТеряетТочки(t *testing.T) {
t.Parallel() t.Parallel()
st := open(t) st := open(t)
@@ -568,7 +568,7 @@ func TestMergePointsКонкурентноеСлияниеНеТеряетТоч
Raw: json.RawMessage(`{"qty":` + itoa(minute) + `}`), Raw: json.RawMessage(`{"qty":` + itoa(minute) + `}`),
}, },
} }
if _, err := st.MergePoints(ctx, []store.IncomingPoint{p}, "d"); err != nil { if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p}}, store.DeliveryRef{ID: "d"}); err != nil {
errs <- err errs <- err
return return
} }
@@ -607,7 +607,7 @@ func itoa(n int) string {
// Изменение запечатанного часа — сигнал, а не отказ: данные пишутся всё равно, // Изменение запечатанного часа — сигнал, а не отказ: данные пишутся всё равно,
// но факт обязан дойти до владельца сервиса. Без счётчика допущение «глубже // но факт обязан дойти до владельца сервиса. Без счётчика допущение «глубже
// такого-то порога досчёта не бывает» не получило бы ни одного наблюдения. // такого-то порога досчёта не бывает» не получило бы ни одного наблюдения.
func TestMergePointsИзменениеЗапечатанногоЧаса(t *testing.T) { func TestMergeИзменениеЗапечатанногоЧаса(t *testing.T) {
t.Parallel() t.Parallel()
st := open(t) st := open(t)
@@ -617,7 +617,7 @@ func TestMergePointsИзменениеЗапечатанногоЧаса(t *test
first := []store.IncomingPoint{ first := []store.IncomingPoint{
point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`), point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
} }
if _, err := st.MergePoints(ctx, first, "d1"); err != nil { if _, err := st.Merge(ctx, store.Incoming{Points: first}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err) t.Fatalf("первое слияние: %v", err)
} }
if err := st.MarkSealed(ctx, "step_count", "minute", hour, true); err != nil { if err := st.MarkSealed(ctx, "step_count", "minute", hour, true); err != nil {
@@ -627,7 +627,7 @@ func TestMergePointsИзменениеЗапечатанногоЧаса(t *test
late := []store.IncomingPoint{ late := []store.IncomingPoint{
point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`), point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`),
} }
stats, err := st.MergePoints(ctx, late, "d2") stats, err := st.Merge(ctx, store.Incoming{Points: late}, store.DeliveryRef{ID: "d2"})
if err != nil { if err != nil {
t.Fatalf("досчёт запечатанного часа: %v", err) t.Fatalf("досчёт запечатанного часа: %v", err)
} }
@@ -649,7 +649,7 @@ func TestMergePointsИзменениеЗапечатанногоЧаса(t *test
// Отмена посреди слияния не имеет права оставить половину: объект либо // Отмена посреди слияния не имеет права оставить половину: объект либо
// прежний, либо полный. // прежний, либо полный.
func TestMergePointsОтменаНеОставляетПоловины(t *testing.T) { func TestMergeОтменаНеОставляетПоловины(t *testing.T) {
t.Parallel() t.Parallel()
st := open(t) st := open(t)
@@ -657,19 +657,30 @@ func TestMergePointsОтменаНеОставляетПоловины(t *testin
base := []store.IncomingPoint{ base := []store.IncomingPoint{
point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`), point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
} }
if _, err := st.MergePoints(context.Background(), base, "d1"); err != nil { if _, err := st.Merge(context.Background(), store.Incoming{Points: base}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err) t.Fatalf("первое слияние: %v", err)
} }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()
more := []store.IncomingPoint{ // Сущности идут в ТОЙ ЖЕ транзакции и пишутся ПОСЛЕ объектов, то есть на
// той половине, где отмена вероятнее. Вынесение их во вторую транзакцию —
// напрашивающаяся правка при жалобе на длину транзакции с маршрутом, и она
// прошла бы зелёной, сломав «состояние пересобираемо»: доставка получила бы
// failed при записанной тренировке.
more := store.Incoming{
Points: []store.IncomingPoint{
point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`), point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`),
},
Workouts: []store.IncomingEntity{workout(t, "w-отменённая", `{"id":"w-отменённая","qty":1}`)},
} }
if _, err := st.MergePoints(ctx, more, "d2"); err == nil { if _, err := st.Merge(ctx, more, store.DeliveryRef{ID: "d2"}); err == nil {
t.Fatal("слияние на отменённом контексте прошло успешно") t.Fatal("слияние на отменённом контексте прошло успешно")
} }
if _, err := st.Workout(context.Background(), "w-отменённая"); !errors.Is(err, store.ErrNotFound) {
t.Errorf("тренировка отменённой доставки осталась в витрине: %v", err)
}
b, err := st.Bucket(context.Background(), "step_count", "minute", ts(t, "2025-06-05T10:00:00Z")) b, err := st.Bucket(context.Background(), "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
if err != nil { if err != nil {
@@ -704,7 +715,7 @@ func cycleTriple(t *testing.T) []store.IncomingPoint {
} }
} }
func TestMergePointsПовторнаяСвёрткаНеМеняетСостояние(t *testing.T) { func TestMergeПовторнаяСвёрткаНеМеняетСостояние(t *testing.T) {
t.Parallel() t.Parallel()
st := open(t) st := open(t)
@@ -714,7 +725,7 @@ func TestMergePointsПовторнаяСвёрткаНеМеняетСостоя
state := func() (string, string) { state := func() (string, string) {
t.Helper() t.Helper()
if _, err := st.MergePoints(ctx, in, "d1"); err != nil { if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("слияние: %v", err) t.Fatalf("слияние: %v", err)
} }
b, err := st.Bucket(ctx, "heart_rate", "raw", ts(t, "2025-06-05T10:00:00Z")) b, err := st.Bucket(ctx, "heart_rate", "raw", ts(t, "2025-06-05T10:00:00Z"))
@@ -738,7 +749,7 @@ func TestMergePointsПовторнаяСвёрткаНеМеняетСостоя
} }
} }
func TestMergePointsИсходНеЗависитОтПерестановки(t *testing.T) { func TestMergeИсходНеЗависитОтПерестановки(t *testing.T) {
t.Parallel() t.Parallel()
ctx := context.Background() ctx := context.Background()
@@ -752,7 +763,7 @@ func TestMergePointsИсходНеЗависитОтПерестановки(t *
st := open(t) st := open(t)
if split { if split {
for _, i := range order { for _, i := range order {
if _, err := st.MergePoints(ctx, []store.IncomingPoint{in[i]}, "d"); err != nil { if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{in[i]}}, store.DeliveryRef{ID: "d"}); err != nil {
t.Fatalf("слияние: %v", err) t.Fatalf("слияние: %v", err)
} }
} }
@@ -761,7 +772,7 @@ func TestMergePointsИсходНеЗависитОтПерестановки(t *
for _, i := range order { for _, i := range order {
batch = append(batch, in[i]) batch = append(batch, in[i])
} }
if _, err := st.MergePoints(ctx, batch, "d"); err != nil { if _, err := st.Merge(ctx, store.Incoming{Points: batch}, store.DeliveryRef{ID: "d"}); err != nil {
t.Fatalf("слияние: %v", err) t.Fatalf("слияние: %v", err)
} }
} }
@@ -785,7 +796,7 @@ func TestMergePointsИсходНеЗависитОтПерестановки(t *
// Точка, ни одно значение которой не несёт измерения, не должна вытеснять // Точка, ни одно значение которой не несёт измерения, не должна вытеснять
// настоящее измерение. Раньше вытесняла: множества содержательных ключей // настоящее измерение. Раньше вытесняла: множества содержательных ключей
// равны, и решал второй разряд — по ключам, а не по содержанию. // равны, и решал второй разряд — по ключам, а не по содержанию.
func TestMergePointsПадингНеВытесняетИзмерение(t *testing.T) { func TestMergeПадингНеВытесняетИзмерение(t *testing.T) {
t.Parallel() t.Parallel()
ctx := context.Background() ctx := context.Background()
@@ -808,7 +819,7 @@ func TestMergePointsПадингНеВытесняетИзмерение(t *test
point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", real), point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", real),
point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", c.junk), point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", c.junk),
} }
stats, err := st.MergePoints(ctx, in, "d1") stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"})
if err != nil { if err != nil {
t.Fatalf("слияние: %v", err) t.Fatalf("слияние: %v", err)
} }
@@ -824,7 +835,7 @@ func TestMergePointsПадингНеВытесняетИзмерение(t *test
// Имя метрики приходит из тела доставки дословно и ничем не ограничено. // Имя метрики приходит из тела доставки дословно и ничем не ограничено.
// Без обрезки одна доставка порождает WARN-строку в десятки мегабайт и // Без обрезки одна доставка порождает WARN-строку в десятки мегабайт и
// вытесняет из ротации логов всю недавнюю историю. // вытесняет из ротации логов всю недавнюю историю.
func TestMergePointsИмяМетрикиВКоординатеОбрезано(t *testing.T) { func TestMergeИмяМетрикиВКоординатеОбрезано(t *testing.T) {
t.Parallel() t.Parallel()
st := open(t) st := open(t)
@@ -835,7 +846,7 @@ func TestMergePointsИмяМетрикиВКоординатеОбрезано(t
point(t, huge, "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`), point(t, huge, "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
point(t, huge, "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":2}`), point(t, huge, "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":2}`),
} }
stats, err := st.MergePoints(ctx, in, "d1") stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"})
if err != nil { if err != nil {
t.Fatalf("слияние: %v", err) t.Fatalf("слияние: %v", err)
} }
+586
View File
@@ -0,0 +1,586 @@
package store
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"git.vakhrushev.me/av/healthlog/internal/canon"
)
// Роды таблиц сущностей. Тренировка живёт в своей таблице: у неё есть
// заголовок, по которому идёт выборка, а у записи его нет.
const (
workoutTable = "workout"
recordTable = "record"
)
// IncomingEntity — сущность с собственным идентификатором, пришедшая на запись.
//
// Содержимое хранится исходными байтами: сущность, пересобранная повторной
// сериализацией, теряет литерал ровно так же, как точка.
type IncomingEntity struct {
ID string
Kind string
Name string
Start time.Time
End time.Time
// OffsetSeconds — смещение зоны начала.
OffsetSeconds int
// Duration — длительность тренировки в секундах. nil означает «источник не
// прислал» и отличим от нуля: ноль — законная длительность.
Duration *float64
Raw json.RawMessage
}
// Incoming — всё, что дала одна доставка. Единицей записи является доставка, а
// не точка и не сущность: частичное состояние ломает инвариант «состояние
// пересобираемо».
type Incoming struct {
Points []IncomingPoint
Workouts []IncomingEntity
Records []IncomingEntity
}
// DeliveryRef — место доставки в журнале. Пара, а не идентификатор: по ней
// разрешается тай-брейк между версиями сущности равной полноты, а порядок
// журнала задан парой `(received_at, id)`.
type DeliveryRef struct {
ID string
ReceivedAt time.Time
}
func (d DeliveryRef) before(other DeliveryRef) bool {
if !d.ReceivedAt.Equal(other.ReceivedAt) {
return d.ReceivedAt.Before(other.ReceivedAt)
}
return d.ID < other.ID
}
// EntityRef — координаты сущности для записи в лог. Содержимого не несёт:
// маршрут тренировки — это геотрек до дома, а метки состояния разума —
// измерение душевного состояния.
type EntityRef struct {
Kind string
ID string
}
// maxEntityRefsReported — сколько координат сущностей попадает в лог.
const maxEntityRefsReported = 5
// prepareEntities считает хеш каждой сущности.
//
// Вынесено из транзакции намеренно: хеширование материализует значение целиком
// (маршрут — до мегабайта), а транзакция повторяется до пяти раз при занятости
// базы.
func prepareEntities(in []IncomingEntity, from DeliveryRef) ([]entityVersion, error) {
if len(in) == 0 {
return nil, nil
}
out := make([]entityVersion, 0, len(in))
for _, e := range in {
v, err := newEntityVersion(e, from)
if err != nil {
return nil, err
}
out = append(out, v)
}
return out, nil
}
// clipRefs держит список координат в потолке: он зацепка для разбора, а не
// отчёт; масштаб события считает счётчик.
func clipRefs(refs []EntityRef) []EntityRef {
if len(refs) <= maxEntityRefsReported {
return refs
}
return refs[:maxEntityRefsReported]
}
// entityVersion — версия сущности вместе с тем, что нужно знать при выборе
// победителя.
//
// Разбор и канонизация ОТЛОЖЕНЫ: они нужны только когда хеш разошёлся с
// сохранённым, то есть на одной доставке из сорока четырёх. Считать их сразу
// значило бы разворачивать маршрут (95% веса тренировки, до мегабайта) в дерево
// значений на каждой копии — ровно та форма, от которой разбор тела отказался
// замером (197 МиБ кучи против 54 МиБ на теле 42 МиБ). Хеш при этом считается
// сразу и один раз на доставку: он и есть быстрый путь.
type entityVersion struct {
raw json.RawMessage
hash string
from DeliveryRef
// key и fields заполняются лениво, методом analyze().
key []byte
fields canon.Fields
parsed bool
// head — заголовок, который пишется колонками. У сохранённой версии он не
// нужен: она либо побеждает и остаётся как есть, либо замещается целиком.
head IncomingEntity
}
func newEntityVersion(e IncomingEntity, from DeliveryRef) (entityVersion, error) {
h, err := canon.Hash(e.Raw)
if err != nil {
return entityVersion{}, fmt.Errorf("хеш сущности: %w", err)
}
return entityVersion{raw: e.Raw, hash: h, from: from, head: e}, nil
}
// analyze разбирает версию, если этого ещё не делали.
func (v *entityVersion) analyze() {
if v.parsed {
return
}
v.key = canon.SortKey(v.raw)
v.fields = canon.Analyze(v.raw)
v.parsed = true
}
// pickEntity выбирает между сохранённой и приехавшей версией.
//
// Второй возврат — потеряла ли бы витрина содержание, приняв приехавшую. Это и
// есть плата за отказ объединять поля: событие не предотвращается молча, а
// считается и уходит в WARN.
//
// 1. приехавшая несёт всё содержание сохранённой и сверх того → приехавшая
// 2. сохранённая несёт всё содержание приехавшей и сверх того → сохранённая
// 3. содержание равно → версия из более поздней доставки ЖУРНАЛА
// 4. наборы несравнимы → сохранённая
//
// Пункт 3 — не «побеждает приехавшая». Приехавшая есть функция порядка
// СВЁРТКИ, а он порядку журнала не равен: воркер сворачивает в порядке журнала
// только среди видимых ему доставок. Доставка с более ранней меткой, свёрнутая
// позже, вернула бы витрину к недосчитанной версии, и пересборка разошлась бы
// с живым приёмом молча — в содержимом тренировки, где это не видно ничем,
// кроме отпечатка.
//
// Равные позиции означают две версии одного ключа внутри ОДНОЙ доставки; там
// решает минимум канонической формы, потому что порядок элементов в
// JSON-массиве нестабилен.
func pickEntity(stored, incoming *entityVersion) (takeIncoming, lost bool) {
switch v := compareEntities(stored, incoming); v {
case entityIncomingRicher:
return true, false
case entityStoredRicher:
return false, true
case entityIncomparable:
// Несравнимы: у каждой версии есть содержание, которого нет у другой.
// Объединение полей отвергнуто там же и по той же причине, что для
// точек, — на живом потоке событие не наступало ни разу, — а из двух
// версий остаётся сохранённая: правило называется «не теряет
// содержания», и приехавшая его теряет. Исход при этом остаётся
// функцией журнала: доставки проигрываются в его порядке.
return false, true
default:
return laterInJournal(stored, incoming), false
}
}
// pickWithinDelivery выбирает между двумя версиями одного ключа ВНУТРИ одной
// доставки. Второй возврат — различается ли их содержание вообще.
//
// Отдельно от pickEntity, и не ради симметрии: «сохранённой» версии здесь нет,
// есть только порядок элементов в JSON-массиве, а он нестабилен. Правило
// «остаётся первая встреченная» сделало бы исход функцией порядка на проводе,
// поэтому при равном и при несравнимом содержании решает тотальный порядок
// канонических форм.
func pickWithinDelivery(a, b *entityVersion) (takeB, differs bool) {
switch v := compareEntities(a, b); v {
case entityIncomingRicher:
return true, true
case entityStoredRicher:
return false, true
case entityIncomparable:
return laterInJournal(a, b), true
default:
return laterInJournal(a, b), false
}
}
// entityVerdict — как соотносится СОДЕРЖАНИЕ двух версий одной сущности.
// Нумерация с единицы: нулевое значение не должно выглядеть как «равны».
type entityVerdict int
const (
// entityEqualContent — множества содержательных ключей совпадают, длины
// верхнеуровневых массивов тоже. Значения при этом могут расходиться: их
// сравнение здесь неприменимо (см. canon.Fields.Covers).
entityEqualContent entityVerdict = iota + 1
entityIncomingRicher
entityStoredRicher
entityIncomparable
)
func compareEntities(stored, incoming *entityVersion) entityVerdict {
stored.analyze()
incoming.analyze()
storedCovers := stored.fields.Covers(incoming.fields)
incomingCovers := incoming.fields.Covers(stored.fields)
switch {
case incomingCovers && storedCovers:
return entityEqualContent
case incomingCovers:
return entityIncomingRicher
case storedCovers:
return entityStoredRicher
default:
return entityIncomparable
}
}
// laterInJournal говорит, стоит ли приехавшая версия позже сохранённой в
// журнале. Позиции равны у двух версий одного ключа внутри одной доставки;
// там решает минимум канонической формы — порядок тотальный и от порядка
// элементов в массиве не зависит.
func laterInJournal(stored, incoming *entityVersion) bool {
if stored.from.before(incoming.from) {
return true
}
if incoming.from.before(stored.from) {
return false
}
return bytes.Compare(incoming.key, stored.key) < 0
}
// dedupeEntities сворачивает версии одного ключа ВНУТРИ доставки тем же
// правилом — до сравнения с сохранённой.
//
// Без этого исход зависел бы от того, как написан цикл: карта по ключу дала бы
// победу последнему элементу массива мимо правила полноты, а порядок элементов
// в JSON-массиве нестабилен.
//
// Счётчик здесь считает СИММЕТРИЧНО — «в одном теле приехали две версии одного
// ключа с разным содержанием», — а не «приехавшая обеднена». Внутри доставки
// «сохранённой» версии не существует, есть только порядок элементов массива, и
// счётчик, зависящий от него, наблюдал бы событие через раз.
func dedupeEntities(versions []entityVersion) ([]entityVersion, int, []EntityRef) {
type slot struct {
v entityVersion
pos int
}
byKey := make(map[EntityRef]slot, len(versions))
order := make([]EntityRef, 0, len(versions))
held := 0
var heldAt []EntityRef
for _, v := range versions {
ref := EntityRef{Kind: v.head.Kind, ID: v.head.ID}
prev, seen := byKey[ref]
if !seen {
byKey[ref] = slot{v: v, pos: len(order)}
order = append(order, ref)
continue
}
takeB, differs := pickWithinDelivery(&prev.v, &v)
if differs {
held++
if len(heldAt) < maxEntityRefsReported {
heldAt = append(heldAt, ref)
}
}
winner := prev.v
if takeB {
winner = v
}
byKey[ref] = slot{v: winner, pos: prev.pos}
}
out := make([]entityVersion, 0, len(order))
for _, ref := range order {
out = append(out, byKey[ref].v)
}
return out, held, heldAt
}
// mergeEntities сливает сущности одной секции с сохранёнными.
func mergeEntities(ctx context.Context, tx *sql.Tx, table string, versions []entityVersion, now time.Time) (written, held int, heldAt []EntityRef, err error) {
for _, v := range versions {
stored, found, err := readEntityHead(ctx, tx, table, v.head.Kind, v.head.ID)
if err != nil {
return 0, 0, nil, err
}
if !found {
if err := writeEntity(ctx, tx, table, v, now); err != nil {
return 0, 0, nil, err
}
written++
continue
}
// Хеш — детектор изменений: совпал, значит писать нечего, и содержимое
// сохранённой сущности читать не приходится вовсе. Тренировка
// переприсылается каждой доставкой, пока не доедет маршрут, — на живом
// архиве 44 копии дают три различных содержимых.
if stored.hash == v.hash {
continue
}
storedRaw, err := readEntityPayload(ctx, tx, table, v.head.Kind, v.head.ID)
if err != nil {
return 0, 0, nil, err
}
prev := entityVersion{raw: storedRaw, hash: stored.hash, from: stored.from}
takeIncoming, lost := pickEntity(&prev, &v)
if lost {
held++
if len(heldAt) < maxEntityRefsReported {
heldAt = append(heldAt, EntityRef{Kind: v.head.Kind, ID: v.head.ID})
}
}
if !takeIncoming {
continue
}
if err := writeEntity(ctx, tx, table, v, now); err != nil {
return 0, 0, nil, err
}
written++
}
return written, held, heldAt, nil
}
type storedEntityHead struct {
hash string
from DeliveryRef
}
func readEntityHead(ctx context.Context, tx *sql.Tx, table, kind, id string) (storedEntityHead, bool, error) {
q := `SELECT content_hash, delivery_id, delivery_received_at FROM ` + table + entityWhere(table)
var (
head storedEntityHead
receivedAt string
)
row := queryEntity(ctx, tx, q, table, kind, id)
err := row.Scan(&head.hash, &head.from.ID, &receivedAt)
if errors.Is(err, sql.ErrNoRows) {
return storedEntityHead{}, false, nil
}
if err != nil {
return storedEntityHead{}, false, fmt.Errorf("select %s: %w", table, err)
}
// Пустую метку не терпим: колонка NOT NULL без умолчания, и пустота здесь
// означала бы дефект писателя. Молчаливый нулевой момент сделал бы
// сохранённую версию «самой ранней в журнале», и её затирала бы любая
// приехавшая — то есть дефект проявился бы потерей данных, а не отказом.
head.from.ReceivedAt, err = ParseTime(receivedAt)
if err != nil {
return storedEntityHead{}, false, err
}
return head, true, nil
}
func readEntityPayload(ctx context.Context, tx *sql.Tx, table, kind, id string) (json.RawMessage, error) {
q := `SELECT payload FROM ` + table + entityWhere(table)
var payload []byte
if err := queryEntity(ctx, tx, q, table, kind, id).Scan(&payload); err != nil {
return nil, fmt.Errorf("select %s payload: %w", table, err)
}
raw, err := gunzipBytes(payload)
if err != nil {
return nil, err
}
return raw, nil
}
// entityWhere и queryEntity держат разницу между таблицами в одном месте:
// у тренировки ключ — `id`, у записи — пара `kind + id`.
func entityWhere(table string) string {
if table == recordTable {
return ` WHERE kind = ? AND id = ?`
}
return ` WHERE id = ?`
}
func queryEntity(ctx context.Context, tx *sql.Tx, q, table, kind, id string) *sql.Row {
if table == recordTable {
return tx.QueryRowContext(ctx, q, kind, id)
}
return tx.QueryRowContext(ctx, q, id)
}
func writeEntity(ctx context.Context, tx *sql.Tx, table string, v entityVersion, now time.Time) error {
payload, err := gzipBytes(v.raw)
if err != nil {
return err
}
stamp := FormatTime(now)
received := FormatTime(v.from.ReceivedAt)
if table == recordTable {
const q = `
INSERT INTO record (kind, id, ts_utc, tz_offset, payload, content_hash,
delivery_id, delivery_received_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (kind, id) DO UPDATE SET
ts_utc = excluded.ts_utc,
tz_offset = excluded.tz_offset,
payload = excluded.payload,
content_hash = excluded.content_hash,
delivery_id = excluded.delivery_id,
delivery_received_at = excluded.delivery_received_at,
updated_at = excluded.updated_at`
if _, err := tx.ExecContext(ctx, q,
v.head.Kind, v.head.ID, FormatTime(v.head.Start), v.head.OffsetSeconds,
payload, v.hash, v.from.ID, received, stamp, stamp); err != nil {
return fmt.Errorf("upsert record: %w", err)
}
return nil
}
const q = `
INSERT INTO workout (id, name, start_utc, end_utc, tz_offset, duration_sec,
payload, content_hash, delivery_id, delivery_received_at,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
name = excluded.name,
start_utc = excluded.start_utc,
end_utc = excluded.end_utc,
tz_offset = excluded.tz_offset,
duration_sec = excluded.duration_sec,
payload = excluded.payload,
content_hash = excluded.content_hash,
delivery_id = excluded.delivery_id,
delivery_received_at = excluded.delivery_received_at,
updated_at = excluded.updated_at`
var duration any
if v.head.Duration != nil {
duration = *v.head.Duration
}
if _, err := tx.ExecContext(ctx, q,
v.head.ID, v.head.Name, FormatTime(v.head.Start), FormatTime(v.head.End),
v.head.OffsetSeconds, duration, payload, v.hash, v.from.ID, received,
stamp, stamp); err != nil {
return fmt.Errorf("upsert workout: %w", err)
}
return nil
}
// Workout — тренировка, прочитанная из витрины. Нужна тестам и будущему
// Read API: содержимое отдаётся целиком, заголовок — из колонок.
type Workout struct {
ID string
Name string
Start time.Time
End time.Time
OffsetSeconds int
Duration *float64
Raw json.RawMessage
Delivery string
}
// Workout читает тренировку по идентификатору.
func (s *Store) Workout(ctx context.Context, id string) (Workout, error) {
const q = `
SELECT id, name, start_utc, end_utc, tz_offset, duration_sec, payload, delivery_id
FROM workout WHERE id = ?`
var (
w Workout
start, end string
duration sql.NullFloat64
payload []byte
deliveryFrom string
)
err := s.db.QueryRowxContext(ctx, q, id).
Scan(&w.ID, &w.Name, &start, &end, &w.OffsetSeconds, &duration, &payload, &deliveryFrom)
if errors.Is(err, sql.ErrNoRows) {
return Workout{}, ErrNotFound
}
if err != nil {
return Workout{}, fmt.Errorf("select workout: %w", err)
}
if w.Start, err = ParseTime(start); err != nil {
return Workout{}, err
}
if w.End, err = ParseTime(end); err != nil {
return Workout{}, err
}
if duration.Valid {
v := duration.Float64
w.Duration = &v
}
if w.Raw, err = gunzipBytes(payload); err != nil {
return Workout{}, err
}
w.Delivery = deliveryFrom
return w, nil
}
// Record — запись секции с собственным идентификатором.
type Record struct {
Kind string
ID string
TS time.Time
OffsetSeconds int
Raw json.RawMessage
Delivery string
}
// Record читает запись по роду и идентификатору.
func (s *Store) Record(ctx context.Context, kind, id string) (Record, error) {
const q = `
SELECT kind, id, ts_utc, tz_offset, payload, delivery_id
FROM record WHERE kind = ? AND id = ?`
var (
r Record
ts string
payload []byte
deliveryFrom string
)
err := s.db.QueryRowxContext(ctx, q, kind, id).
Scan(&r.Kind, &r.ID, &ts, &r.OffsetSeconds, &payload, &deliveryFrom)
if errors.Is(err, sql.ErrNoRows) {
return Record{}, ErrNotFound
}
if err != nil {
return Record{}, fmt.Errorf("select record: %w", err)
}
if r.TS, err = ParseTime(ts); err != nil {
return Record{}, err
}
if r.Raw, err = gunzipBytes(payload); err != nil {
return Record{}, err
}
r.Delivery = deliveryFrom
return r, nil
}
// CountWorkouts и CountRecords нужны отчёту пересборки: отпечаток отвечает
// «да/нет», а по «да/нет» нельзя судить о направлении расхождения.
func (s *Store) CountWorkouts(ctx context.Context) (int64, error) {
var n int64
if err := s.db.GetContext(ctx, &n, `SELECT count(*) FROM workout`); err != nil {
return 0, fmt.Errorf("count workouts: %w", err)
}
return n, nil
}
// CountRecords возвращает число записей секций с собственным `id`.
func (s *Store) CountRecords(ctx context.Context) (int64, error) {
var n int64
if err := s.db.GetContext(ctx, &n, `SELECT count(*) FROM record`); err != nil {
return 0, fmt.Errorf("count records: %w", err)
}
return n, nil
}
+460
View File
@@ -0,0 +1,460 @@
package store_test
import (
"context"
"encoding/json"
"testing"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// workout собирает тренировку с заданным содержимым. Заголовок в этих тестах
// вторичен: правило замены смотрит на содержание, а не на колонки.
func workout(t *testing.T, id, raw string) store.IncomingEntity {
t.Helper()
return store.IncomingEntity{
ID: id,
Kind: "workouts",
Name: "На улице Ходьба",
Start: ts(t, "2025-06-05T07:00:00Z"),
End: ts(t, "2025-06-05T07:10:00Z"),
OffsetSeconds: 3 * 3600,
Raw: json.RawMessage(raw),
}
}
func from(t *testing.T, id, receivedAt string) store.DeliveryRef {
t.Helper()
return store.DeliveryRef{ID: id, ReceivedAt: ts(t, receivedAt)}
}
func mergeWorkouts(t *testing.T, st *store.Store, d store.DeliveryRef, ws ...store.IncomingEntity) store.MergeStats {
t.Helper()
stats, err := st.Merge(context.Background(), store.Incoming{Workouts: ws}, d)
if err != nil {
t.Fatalf("слияние сущностей: %v", err)
}
return stats
}
const (
// Содержимое подобрано так, чтобы отличаться от прежнего ЗНАЧЕНИЯМИ общих
// полей: именно так тренировка и меняется между версиями (досчёт энергии),
// и именно на этом ломается правило полноты, написанное для точек.
woWithRoute = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1},{"lat":2},{"lat":3}],
"activeEnergy":[{"qty":10}],"totalEnergy":{"qty":20,"units":"kJ"}}`
woNoRouteNewValues = `{"id":"w1","name":"На улице Ходьба",
"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"}}`
woShortRoute = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1}],
"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"}}`
woRicher = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1},{"lat":2},{"lat":3}],
"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"},"stepCount":{"qty":900}}`
woSameShapeNewValues = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1},{"lat":2},{"lat":3}],
"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"}}`
woIncomparable = `{"id":"w1","name":"На улице Ходьба",
"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"},"flightsClimbed":{"qty":3}}`
)
func storedRaw(t *testing.T, st *store.Store, id string) string {
t.Helper()
w, err := st.Workout(context.Background(), id)
if err != nil {
t.Fatalf("чтение тренировки: %v", err)
}
return string(w.Raw)
}
// Единственная причина повторной присылки тренировки — доезжающий маршрут.
func TestMergeДоехавшийМаршрутЗамещаетТренировку(t *testing.T) {
t.Parallel()
st := open(t)
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woNoRouteNewValues))
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woWithRoute))
if stats.WorkoutsWritten != 1 {
t.Errorf("записано %d, ожидалась 1: версия с маршрутом полнее", stats.WorkoutsWritten)
}
if stats.EntitiesHeld != 0 {
t.Errorf("удержано %d, ожидалось 0", stats.EntitiesHeld)
}
if got := storedRaw(t, st, "w1"); got != woWithRoute {
t.Error("в витрине не версия с маршрутом")
}
}
// Тренировка досчитывается задним числом ровно так же, как минутное ведро:
// набор полей тот же, значения новые. Тай-брейк по канонической форме
// заморозил бы её на произвольной версии навсегда.
func TestMergeДосчётПриТомЖеНабореПолейПобеждает(t *testing.T) {
t.Parallel()
st := open(t)
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woSameShapeNewValues))
if stats.WorkoutsWritten != 1 {
t.Errorf("записано %d, ожидалась 1", stats.WorkoutsWritten)
}
if got := storedRaw(t, st, "w1"); got != woSameShapeNewValues {
t.Error("досчитанная версия не легла в витрину")
}
}
// Исход обязан быть функцией ЖУРНАЛА, а не порядка свёртки: воркер сворачивает
// в порядке журнала только среди видимых ему доставок, и доставка с более
// ранней меткой может свернуться позже.
func TestMergeВерсияИзБолееРаннейДоставкиНеОткатываетВитрину(t *testing.T) {
t.Parallel()
early := from(t, "d1", "2025-06-05T08:00:00Z")
late := from(t, "d2", "2025-06-05T08:05:00Z")
прямой := open(t)
mergeWorkouts(t, прямой, early, workout(t, "w1", woWithRoute))
mergeWorkouts(t, прямой, late, workout(t, "w1", woSameShapeNewValues))
обратный := open(t)
mergeWorkouts(t, обратный, late, workout(t, "w1", woSameShapeNewValues))
mergeWorkouts(t, обратный, early, workout(t, "w1", woWithRoute))
a, b := storedRaw(t, прямой, "w1"), storedRaw(t, обратный, "w1")
if a != b {
t.Error("исход зависит от порядка свёртки — живая витрина разойдётся с пересборкой")
}
if a != woSameShapeNewValues {
t.Error("победила версия не из более поздней доставки журнала")
}
}
// Маршрут — 95% содержимого тренировки, а восстановление требует пересборки
// всего журнала. Событие делается наблюдаемым, а не необратимым.
func TestMergeОбеднённаяВерсияНеЗатираетСохранённую(t *testing.T) {
t.Parallel()
st := open(t)
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woNoRouteNewValues))
if stats.WorkoutsWritten != 0 {
t.Errorf("записано %d, ожидалось 0: приехавшая теряет маршрут", stats.WorkoutsWritten)
}
if stats.EntitiesHeld != 1 {
t.Errorf("удержано %d, ожидалась 1 — событие обязано быть видно", stats.EntitiesHeld)
}
if len(stats.HeldAt) != 1 || stats.HeldAt[0].ID != "w1" {
t.Errorf("координаты удержанной версии %v", stats.HeldAt)
}
if got := storedRaw(t, st, "w1"); got != woWithRoute {
t.Error("маршрут пропал из витрины")
}
}
// Усечённый маршрут ключа не теряет — множеств ключей мало.
func TestMergeУсечённыйМаршрутНеЗатираетСохранённый(t *testing.T) {
t.Parallel()
st := open(t)
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woShortRoute))
if stats.EntitiesHeld != 1 {
t.Errorf("удержано %d, ожидалась 1", stats.EntitiesHeld)
}
if got := storedRaw(t, st, "w1"); got != woWithRoute {
t.Error("полный маршрут вытеснен усечённым")
}
}
// Несравнимые наборы: у каждой версии есть содержание, которого нет у другой.
// Поля не объединяются, вместо этого — счётчик.
func TestMergeНесравнимыеНаборыНеОбъединяются(t *testing.T) {
t.Parallel()
st := open(t)
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woIncomparable))
if stats.EntitiesHeld != 1 {
t.Errorf("удержано %d, ожидалась 1", stats.EntitiesHeld)
}
if got := storedRaw(t, st, "w1"); got != woWithRoute {
t.Error("несравнимая версия заместила сохранённую")
}
}
// Хеш — детектор изменений: тренировка переприсылается каждой доставкой, пока
// не доедет маршрут, и 41 копия из 44 записи вызывать не должна.
func TestMergeПовторТойЖеТренировкиНеПишет(t *testing.T) {
t.Parallel()
st := open(t)
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
// Тот же смысл, другой порядок ключей и дребезг литерала: каноническая
// форма обязана совпасть.
same := `{"name":"На улице Ходьба","id":"w1","totalEnergy":{"units":"kJ","qty":20.0},
"activeEnergy":[{"qty":10}],"route":[{"lat":1},{"lat":2},{"lat":3}]}`
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", same))
if stats.WorkoutsWritten != 0 {
t.Errorf("записано %d, ожидалось 0: содержимое то же", stats.WorkoutsWritten)
}
}
// Три версии в разных порядках подачи: пункты правила, не зависящие от порядка
// свёртки, обязаны давать одно состояние. Конвенция требует перестановки трёх,
// а не пары: попарная свёртка уже давала нетранзитивную победу на точках.
func TestMergeПерестановкаТрёхВерсийДаётОдноСостояние(t *testing.T) {
t.Parallel()
type step struct {
d store.DeliveryRef
raw string
}
steps := []step{
{from(t, "d1", "2025-06-05T08:00:00Z"), woNoRouteNewValues},
{from(t, "d2", "2025-06-05T08:05:00Z"), woWithRoute},
{from(t, "d3", "2025-06-05T08:10:00Z"), woRicher},
}
orders := [][]int{{0, 1, 2}, {2, 1, 0}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {0, 2, 1}}
var want string
for i, order := range orders {
st := open(t)
for _, idx := range order {
mergeWorkouts(t, st, steps[idx].d, workout(t, "w1", steps[idx].raw))
}
got := storedRaw(t, st, "w1")
if i == 0 {
want = got
if want != woRicher {
t.Fatalf("победила не самая полная версия")
}
continue
}
if got != want {
t.Errorf("порядок %v дал другое состояние", order)
}
}
}
// Порядок элементов в JSON-массиве нестабилен, поэтому две версии одного ключа
// внутри одной доставки не имеют права разрешаться «последним в массиве».
func TestMergeДвеВерсииВОдномТелеНеЗависятОтПорядка(t *testing.T) {
t.Parallel()
d := from(t, "d1", "2025-06-05T08:00:00Z")
прямой := open(t)
mergeWorkouts(t, прямой, d, workout(t, "w1", woWithRoute), workout(t, "w1", woSameShapeNewValues))
обратный := open(t)
mergeWorkouts(t, обратный, d, workout(t, "w1", woSameShapeNewValues), workout(t, "w1", woWithRoute))
if storedRaw(t, прямой, "w1") != storedRaw(t, обратный, "w1") {
t.Error("исход зависит от порядка элементов в массиве секции")
}
}
// Записи разных родов с одним идентификатором — разные записи: ключ пара, а не
// один id.
func TestMergeЗаписиРазныхРодовСОднимIDНеСталкиваются(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
mind := store.IncomingEntity{
ID: "e1", Kind: "stateOfMind",
Start: ts(t, "2025-06-05T18:00:00Z"),
End: ts(t, "2025-06-05T18:00:00Z"),
Raw: json.RawMessage(`{"id":"e1","kind":"momentary_emotion","valence":0.5}`),
}
ecg := store.IncomingEntity{
ID: "e1", Kind: "ecg",
Start: ts(t, "2025-06-05T19:00:00Z"),
End: ts(t, "2025-06-05T19:00:00Z"),
Raw: json.RawMessage(`{"id":"e1","classification":"sinusRhythm"}`),
}
if _, err := st.Merge(ctx, store.Incoming{Records: []store.IncomingEntity{mind, ecg}},
from(t, "d1", "2025-06-05T20:00:00Z")); err != nil {
t.Fatalf("слияние записей: %v", err)
}
for _, kind := range []string{"stateOfMind", "ecg"} {
if _, err := st.Record(ctx, kind, "e1"); err != nil {
t.Errorf("запись рода %q не найдена: %v", kind, err)
}
}
}
// Отпечаток — единственный оракул сходимости. Витрины, совпадающие по часовым
// объектам, но разошедшиеся в тренировке, обязаны давать разные отпечатки.
func TestFingerprintРазличаетТренировки(t *testing.T) {
t.Parallel()
ctx := context.Background()
build := func(raw string) string {
st := open(t)
in := store.Incoming{
Points: []store.IncomingPoint{point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)},
Workouts: []store.IncomingEntity{workout(t, "w1", raw)},
}
if _, err := st.Merge(ctx, in, from(t, "d1", "2025-06-05T08:00:00Z")); err != nil {
t.Fatalf("слияние: %v", err)
}
fp, err := st.Fingerprint(ctx)
if err != nil {
t.Fatalf("отпечаток: %v", err)
}
return fp
}
full, changed := build(woWithRoute), build(woSameShapeNewValues)
if full == changed {
t.Error("отпечатки совпали при разошедшемся содержимом тренировки")
}
if again := build(woWithRoute); again != full {
t.Error("отпечаток не воспроизводится на одном содержимом")
}
}
// Длительность: ноль — законное измерение, а «не прислали» обязано быть
// отличимо от него.
func TestWorkoutДлительностьОтличаетНольОтОтсутствия(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
zero := 0.0
withZero := workout(t, "w-zero", `{"id":"w-zero","qty":1}`)
withZero.Duration = &zero
withNone := workout(t, "w-none", `{"id":"w-none","qty":1}`)
if _, err := st.Merge(ctx, store.Incoming{Workouts: []store.IncomingEntity{withZero, withNone}},
from(t, "d1", "2025-06-05T08:00:00Z")); err != nil {
t.Fatalf("слияние: %v", err)
}
w, err := st.Workout(ctx, "w-zero")
if err != nil {
t.Fatalf("чтение: %v", err)
}
if w.Duration == nil || *w.Duration != 0 {
t.Errorf("нулевая длительность потерялась: %v", w.Duration)
}
w, err = st.Workout(ctx, "w-none")
if err != nil {
t.Fatalf("чтение: %v", err)
}
if w.Duration != nil {
t.Errorf("отсутствие длительности стало значением %v", *w.Duration)
}
}
// Содержимое сущности хранится дословно: побайтовый круг «запись → чтение».
func TestWorkoutСодержимоеХранитсяДословно(t *testing.T) {
t.Parallel()
st := open(t)
// Литералы, которые теряет любая пересборка через разобранные значения:
// `1.0`, целое больше 2^53, дробь длиннее двенадцати значащих цифр, а также
// символы, которые json.Marshal экранирует по умолчанию.
const raw = `{"id":"w9","route":[{"lat":1.0,"big":9007199254740993,"p":0.123456789012345678}],
"note":"a<b&c>d","isIndoor":false}`
if _, err := st.Merge(context.Background(),
store.Incoming{Workouts: []store.IncomingEntity{workout(t, "w9", raw)}},
from(t, "d1", "2025-06-05T08:00:00Z")); err != nil {
t.Fatalf("слияние: %v", err)
}
if got := storedRaw(t, st, "w9"); got != raw {
t.Errorf("содержимое изменилось при хранении:\nбыло %s\nстало %s", raw, got)
}
}
// Провенанс нужен не отчётности: по нему разрешается тай-брейк, и без него
// запись WARN об удержанной версии не связать с телом в архиве.
func TestWorkoutНесётПровенанс(t *testing.T) {
t.Parallel()
st := open(t)
mergeWorkouts(t, st, from(t, "d7", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
w, err := st.Workout(context.Background(), "w1")
if err != nil {
t.Fatalf("чтение: %v", err)
}
if w.Delivery != "d7" {
t.Errorf("провенанс %q, ожидался d7", w.Delivery)
}
if w.Start.IsZero() || w.End.Before(w.Start) {
t.Errorf("интервал заголовка неверен: %v — %v", w.Start, w.End)
}
if w.OffsetSeconds != 3*3600 {
t.Errorf("офсет %d, ожидался 10800", w.OffsetSeconds)
}
}
// Внутри одной доставки «сохранённой» версии не существует — есть только
// порядок элементов в JSON-массиве, а он нестабилен. Ни исход, ни счётчик не
// имеют права от него зависеть.
func TestMergeНесравнимыеВерсииВОдномТелеНеЗависятОтПорядка(t *testing.T) {
t.Parallel()
d := from(t, "d1", "2025-06-05T08:00:00Z")
const withRoute = `{"id":"w1","route":[{"lat":1}],"stepCount":{"qty":9}}`
const withFlights = `{"id":"w1","route":[{"lat":1}],"flightsClimbed":{"qty":3}}`
прямой := open(t)
a := mergeWorkouts(t, прямой, d, workout(t, "w1", withRoute), workout(t, "w1", withFlights))
обратный := open(t)
b := mergeWorkouts(t, обратный, d, workout(t, "w1", withFlights), workout(t, "w1", withRoute))
if storedRaw(t, прямой, "w1") != storedRaw(t, обратный, "w1") {
t.Error("исход зависит от порядка элементов в массиве секции")
}
if a.EntitiesHeld != b.EntitiesHeld {
t.Errorf("счётчик зависит от порядка: %d против %d", a.EntitiesHeld, b.EntitiesHeld)
}
if a.EntitiesHeld == 0 {
t.Error("две версии с разным содержанием в одном теле остались незамеченными")
}
}
// Отпечаток обязан различать состояния, а не только содержимое: составной ключ
// записи, склеенный до взятия длины, даёт (`a`, `b/c`) = (`a/b`, `c`).
func TestFingerprintРазличаетСоставнойКлючЗаписи(t *testing.T) {
t.Parallel()
ctx := context.Background()
build := func(kind, id string) string {
st := open(t)
rec := store.IncomingEntity{
ID: id, Kind: kind,
Start: ts(t, "2025-06-05T18:00:00Z"),
End: ts(t, "2025-06-05T18:00:00Z"),
Raw: json.RawMessage(`{"id":"x","valence":0.5}`),
}
if _, err := st.Merge(ctx, store.Incoming{Records: []store.IncomingEntity{rec}},
from(t, "d1", "2025-06-05T08:00:00Z")); err != nil {
t.Fatalf("слияние: %v", err)
}
fp, err := st.Fingerprint(ctx)
if err != nil {
t.Fatalf("отпечаток: %v", err)
}
return fp
}
if build("a", "b/c") == build("a/b", "c") {
t.Error("два разных состояния витрины дали один отпечаток: составной ключ склеен до взятия длины")
}
}
+74
View File
@@ -88,3 +88,77 @@ func TestMigrationПрежниеParsedСтановятсяPending(t *testing.T)
} }
} }
} }
// Миграция 00007 исполняет правило «покрыли секцию — пересверните»: список
// непокрытых ключей это снимок покрытия на момент свёртки, и доставки,
// свёрнутые до того, как workouts и stateOfMind стали покрытыми, остались бы
// `partial` со старым списком навсегда — а ретеншен вечно щадил бы их тела.
//
// Перевод ТОЧЕЧНЫЙ: доставка, у которой непокрыта только `ecg`, пересворачивать
// нечего, и трогать её значило бы гонять весь архив на каждую новую секцию.
func TestMigrationПокрытыеСекцииВозвращаютсяВОчередь(t *testing.T) {
db, err := sqlx.Connect("sqlite", dsn(filepath.Join(t.TempDir(), "healthlog.db")))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
sub, err := fs.Sub(migrationsFS, "migrations")
if err != nil {
t.Fatalf("миграции: %v", err)
}
p, err := goose.NewProvider(goose.DialectSQLite3, db.DB, sub)
if err != nil {
t.Fatalf("провайдер: %v", err)
}
ctx := context.Background()
if _, err := p.UpTo(ctx, 6); err != nil {
t.Fatalf("миграция до 6: %v", err)
}
const insert = `
INSERT INTO delivery (id, received_at, automation_name, automation_id,
aggregation, period, session_id, bytes, sha256, raw_path,
parse_status, points, uncovered_sections)
VALUES (?, '2025-06-05T10:00:00Z', '', '', '', '', '', 0, '-', '-', ?, 0, ?)`
cases := []struct {
id string
status string
uncovered string
want string
}{
{"d-workouts", ParsePartial, `["workouts"]`, ParsePending},
{"d-mind", ParsePartial, `["stateOfMind"]`, ParsePending},
{"d-both", ParsePartial, `["stateOfMind","workouts"]`, ParsePending},
{"d-mixed", ParsePartial, `["ecg","workouts"]`, ParsePending},
// Ничего из ставшего покрытым: трогать нечего.
{"d-ecg", ParsePartial, `["ecg"]`, ParsePartial},
// Подстрока имени секции — не имя секции: отбор идёт по элементу
// массива, иначе чужое тело управляло бы тем, что мы пересворачиваем.
{"d-lookalike", ParsePartial, `["myworkoutsx"]`, ParsePartial},
// Статус `failed` возвращает только пересборка, а `parsed` этой
// миграцией не трогается: у него пустой список непокрытых.
{"d-failed", ParseFailed, `["workouts"]`, ParseFailed},
{"d-parsed", ParseDone, `[]`, ParseDone},
}
for _, c := range cases {
if _, err := db.ExecContext(ctx, insert, c.id, c.status, c.uncovered); err != nil {
t.Fatalf("вставка %s: %v", c.id, err)
}
}
if _, err := p.UpTo(ctx, 7); err != nil {
t.Fatalf("миграция до 7: %v", err)
}
for _, c := range cases {
var got string
if err := db.GetContext(ctx, &got, `SELECT parse_status FROM delivery WHERE id = ?`, c.id); err != nil {
t.Fatalf("чтение %s: %v", c.id, err)
}
if got != c.want {
t.Errorf("%s: статус %q, ожидался %q (непокрытые %s)", c.id, got, c.want, c.uncovered)
}
}
}
@@ -0,0 +1,123 @@
-- +goose Up
-- Вторая единица хранения витрины: сущность с собственным идентификатором.
-- Часовой объект ей не подходит — у неё есть естественный ключ, она редка (за
-- двое суток потока две тренировки и две записи состояния разума при 44 и 52
-- доставленных копиях), и группировать её по часам незачем.
--
-- Таблицы две, а не одна с колонкой рода: у тренировки есть заголовок, по
-- которому идёт выборка (имя, интервал, длительность), а у записи его нет.
-- Общая таблица либо теряла бы заголовок, либо держала колонки, пустые у пяти
-- родов из шести.
CREATE TABLE workout (
-- Идентификатор из HealthKit. Приходит из тела и ограничен по длине
-- разбором: уезжает и в ключ, и в записи лога.
id TEXT PRIMARY KEY,
-- Имя как прислал HAE, локализованное («В помещении Ходьба» — машинная
-- калька с Indoor Walk). Хранится дословно; стабильный код HealthKit
-- припишет задача словаря категориальных значений.
name TEXT NOT NULL DEFAULT '',
-- Интервал в UTC, RFC 3339. Конец, которого нет или который не читается,
-- равен началу: ключ — id, схлопывать координаты нечем, а истина остаётся
-- в payload.
start_utc TEXT NOT NULL,
end_utc TEXT NOT NULL,
-- Смещение зоны НАЧАЛА: колонка одна, а тренировка через смену зоны дала
-- бы два разных.
tz_offset INTEGER NOT NULL DEFAULT 0,
-- Длительность в секундах, как прислал HAE. NULL означает «источник не
-- прислал»: ноль — законная длительность, и потребитель, сложивший
-- столбец, иначе не отличил бы одно от другого. Не вычисляется из
-- интервала — HAE шлёт 91.746 при интервале в 91 секунду.
duration_sec REAL,
-- Тренировка целиком исходными байтами, сжатая gzip: заголовок, маршрут,
-- внутренние ряды и сводки. Маршрут — 95% веса (190 КБ из 199,6 у
-- десятиминутной прогулки), а такой JSON жмётся примерно в 25 раз.
-- Внутрь средствами SQL не заглянуть — та же плата, что у bucket.payload.
payload BLOB NOT NULL,
-- Хеш канонической формы содержимого: детектор изменений, а не ключ.
-- Тренировка переприсылается каждой доставкой, пока не доедет маршрут: 44
-- копии на живом архиве дают три различных содержимых.
content_hash TEXT NOT NULL,
-- Провенанс: доставка, ЧЬЯ ВЕРСИЯ лежит сейчас, и её метка приёма. Не
-- отчётность: по паре (received_at, id) разрешается тай-брейк между
-- версиями равной полноты. «Побеждает приехавшая» было бы функцией порядка
-- свёртки, а он порядку журнала не равен — доставка с более ранней меткой,
-- свёрнутая позже, вернула бы витрину к недосчитанной версии, и пересборка
-- разошлась бы с живым приёмом молча.
-- Без DEFAULT намеренно: единственный писатель заполняет обе колонки
-- всегда, а умолчание превратило бы его дефект из отказа вставки в тихо
-- неверный исход — строка с пустой меткой оказалась бы «самой ранней в
-- журнале», и её затирала бы любая приехавшая версия.
delivery_id TEXT NOT NULL,
delivery_received_at TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Основной запрос трекера — «заголовки тренировок за период».
CREATE INDEX workout_start_utc ON workout (start_utc);
CREATE TABLE record (
-- Род — верхнеуровневый ключ секции HAE ДОСЛОВНО (`stateOfMind`, не
-- `state_of_mind`): инвариант «форма Apple не транслируется» относится и к
-- именам секций.
kind TEXT NOT NULL,
id TEXT NOT NULL,
-- Метка события в UTC и смещение исходной зоны. У stateOfMind HAE шлёт
-- RFC 3339 в UTC, поэтому смещение там всегда 0 — это значит «источник
-- прислал UTC», а не «человек был в Гринвиче»: местной зоны у секции в
-- потоке нет вовсе.
ts_utc TEXT NOT NULL,
tz_offset INTEGER NOT NULL DEFAULT 0,
payload BLOB NOT NULL,
content_hash TEXT NOT NULL,
delivery_id TEXT NOT NULL,
delivery_received_at TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
-- Ключ — ПАРА, а не один id. Собственный id наблюдался живьём только у
-- stateOfMind, где он UUID HealthKit; форма идентификатора остальных пяти
-- секций не наблюдалась никем, и короткий несквозной id в двух разных
-- секциях затёр бы одну запись другой молча. Пара стоит ноль: запросы к
-- записям всегда идут с родом.
PRIMARY KEY (kind, id)
);
-- «Записи такого-то рода за период» — единственная форма запроса к таблице.
CREATE INDEX record_kind_ts ON record (kind, ts_utc);
-- Покрыли секцию — пересверните. Список непокрытых ключей это снимок покрытия
-- на момент свёртки: доставки, свёрнутые до того, как workouts и stateOfMind
-- стали покрытыми, остались бы partial со старым списком, и ретеншен вечно
-- щадил бы тела, которые больше ничего не хранят сверх витрины.
--
-- Отбор по ЭЛЕМЕНТУ массива, а не по подстроке тела: имя секции приходит из
-- чужого тела, и LIKE '%workouts%' поймал бы ключ, лишь содержащий эту
-- подстроку. Перевод точечный, а не «все partial»: доставка с непокрытой ecg
-- пересворачивать нечего.
UPDATE delivery
SET parse_status = 'pending'
WHERE parse_status = 'partial'
AND EXISTS (
SELECT 1 FROM json_each(delivery.uncovered_sections)
WHERE json_each.value IN ('workouts', 'stateOfMind')
);
-- +goose Down
-- Строки, переведённые в pending, Down обратно не возвращает: какими они были,
-- восстановить неоткуда, а pending консервативен — ретеншен его не трогает.
DROP INDEX record_kind_ts;
DROP TABLE record;
DROP INDEX workout_start_utc;
DROP TABLE workout;
+16 -16
View File
@@ -17,7 +17,7 @@ import (
// каждом глубоком проходе, и настоящий отказ правила слияния становился // каждом глубоком проходе, и настоящий отказ правила слияния становился
// неотличим от нормы — при том что счётчик перезаписей объявлен единственным // неотличим от нормы — при том что счётчик перезаписей объявлен единственным
// наблюдением за этим правилом. // наблюдением за этим правилом.
func TestMergePointsДребезгНеСчитаетсяСтолкновением(t *testing.T) { func TestMergeДребезгНеСчитаетсяСтолкновением(t *testing.T) {
t.Parallel() t.Parallel()
cases := map[string][2]string{ cases := map[string][2]string{
@@ -41,10 +41,10 @@ func TestMergePointsДребезгНеСчитаетсяСтолкновение
first := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", pair[0]) first := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", pair[0])
second := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", pair[1]) second := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", pair[1])
if _, err := st.MergePoints(ctx, []store.IncomingPoint{first}, "d1"); err != nil { if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{first}}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err) t.Fatalf("первое слияние: %v", err)
} }
stats, err := st.MergePoints(ctx, []store.IncomingPoint{second}, "d2") stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{second}}, store.DeliveryRef{ID: "d2"})
if err != nil { if err != nil {
t.Fatalf("второе слияние: %v", err) t.Fatalf("второе слияние: %v", err)
} }
@@ -63,7 +63,7 @@ func TestMergePointsДребезгНеСчитаетсяСтолкновение
// Настоящее столкновение обязано оставить след с координатами объекта: одно // Настоящее столкновение обязано оставить след с координатами объекта: одно
// число `overwrites` не говорит, какая метрика и какой час пострадали, и // число `overwrites` не говорит, какая метрика и какой час пострадали, и
// расследовать перезапись по нему нечем. // расследовать перезапись по нему нечем.
func TestMergePointsСтолкновениеОставляетКоординаты(t *testing.T) { func TestMergeСтолкновениеОставляетКоординаты(t *testing.T) {
t.Parallel() t.Parallel()
st := open(t) st := open(t)
@@ -74,10 +74,10 @@ func TestMergePointsСтолкновениеОставляетКоординат
poor := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", poor := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"Avg":61}`) `{"Avg":61}`)
if _, err := st.MergePoints(ctx, []store.IncomingPoint{rich}, "d1"); err != nil { if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{rich}}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err) t.Fatalf("первое слияние: %v", err)
} }
stats, err := st.MergePoints(ctx, []store.IncomingPoint{poor}, "d2") stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{poor}}, store.DeliveryRef{ID: "d2"})
if err != nil { if err != nil {
t.Fatalf("второе слияние: %v", err) t.Fatalf("второе слияние: %v", err)
} }
@@ -98,7 +98,7 @@ func TestMergePointsСтолкновениеОставляетКоординат
// экранирует `&`, `<` и `>` внутри содержимого — порчи значений это не даёт, но // экранирует `&`, `<` и `>` внутри содержимого — порчи значений это не даёт, но
// обещание перестаёт быть правдой, а сравнение байтов при следующей доставке // обещание перестаёт быть правдой, а сравнение байтов при следующей доставке
// той же точки начинает промахиваться навсегда. // той же точки начинает промахиваться навсегда.
func TestMergePointsХранитУгловыеСкобкиИАмперсанд(t *testing.T) { func TestMergeХранитУгловыеСкобкиИАмперсанд(t *testing.T) {
t.Parallel() t.Parallel()
st := open(t) st := open(t)
@@ -107,7 +107,7 @@ func TestMergePointsХранитУгловыеСкобкиИАмперсанд(t
raw := `{"qty":1,"source":"Anton & Co <iPhone> \"x\""}` raw := `{"qty":1,"source":"Anton & Co <iPhone> \"x\""}`
in := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", raw) in := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", raw)
if _, err := st.MergePoints(ctx, []store.IncomingPoint{in}, "d1"); err != nil { if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{in}}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("слияние: %v", err) t.Fatalf("слияние: %v", err)
} }
@@ -129,7 +129,7 @@ func TestMergePointsХранитУгловыеСкобкиИАмперсанд(t
// Смена единиц не имеет права молча переподписать уже сохранённые точки: // Смена единиц не имеет права молча переподписать уже сохранённые точки:
// внутри точки единиц нет, и у ранних точек не остаётся ничего, по чему их // внутри точки единиц нет, и у ранних точек не остаётся ничего, по чему их
// единицы восстановимы. Правило «первое непустое побеждает» плюс счётчик. // единицы восстановимы. Правило «первое непустое побеждает» плюс счётчик.
func TestMergePointsСменаЕдиницНеПерезаписываетМолча(t *testing.T) { func TestMergeСменаЕдиницНеПерезаписываетМолча(t *testing.T) {
t.Parallel() t.Parallel()
st := open(t) st := open(t)
@@ -148,10 +148,10 @@ func TestMergePointsСменаЕдиницНеПерезаписываетМол
mi.End = mi.Start mi.End = mi.Start
mi.Raw = []byte(`{"qty":2}`) mi.Raw = []byte(`{"qty":2}`)
if _, err := st.MergePoints(ctx, []store.IncomingPoint{km}, "d1"); err != nil { if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{km}}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err) t.Fatalf("первое слияние: %v", err)
} }
stats, err := st.MergePoints(ctx, []store.IncomingPoint{mi}, "d2") stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{mi}}, store.DeliveryRef{ID: "d2"})
if err != nil { if err != nil {
t.Fatalf("второе слияние: %v", err) t.Fatalf("второе слияние: %v", err)
} }
@@ -173,14 +173,14 @@ func TestMergePointsСменаЕдиницНеПерезаписываетМол
// доставки схлопываются, и счётчик присланных систематически завышал бы // доставки схлопываются, и счётчик присланных систематически завышал бы
// содержимое витрины — расхождение «прислали 1000, лежит 700» было бы невидимо // содержимое витрины — расхождение «прислали 1000, лежит 700» было бы невидимо
// ровно тогда, когда точки начнут теряться по-настоящему. // ровно тогда, когда точки начнут теряться по-настоящему.
func TestMergePointsСчитаетСохранённые(t *testing.T) { func TestMergeСчитаетСохранённые(t *testing.T) {
t.Parallel() t.Parallel()
st := open(t) st := open(t)
ctx := context.Background() ctx := context.Background()
p := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`) p := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)
stats, err := st.MergePoints(ctx, []store.IncomingPoint{p, p, p}, "d1") stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p, p, p}}, store.DeliveryRef{ID: "d1"})
if err != nil { if err != nil {
t.Fatalf("слияние: %v", err) t.Fatalf("слияние: %v", err)
} }
@@ -188,7 +188,7 @@ func TestMergePointsСчитаетСохранённые(t *testing.T) {
t.Errorf("сохранено %d точек, ожидалась 1 (три точных повтора)", stats.Stored) t.Errorf("сохранено %d точек, ожидалась 1 (три точных повтора)", stats.Stored)
} }
stats, err = st.MergePoints(ctx, []store.IncomingPoint{p}, "d2") stats, err = st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p}}, store.DeliveryRef{ID: "d2"})
if err != nil { if err != nil {
t.Fatalf("повтор: %v", err) t.Fatalf("повтор: %v", err)
} }
@@ -201,7 +201,7 @@ func TestMergePointsСчитаетСохранённые(t *testing.T) {
// оставляет частичного состояния — а значит и не зависит от порядка обхода. // оставляет частичного состояния — а значит и не зависит от порядка обхода.
// Раньше транзакция была на объект, и восемь прогонов одной доставки давали // Раньше транзакция была на объект, и восемь прогонов одной доставки давали
// семь разных наборов записанных объектов. // семь разных наборов записанных объектов.
func TestMergePointsОтказНеОставляетЧастиОбъектов(t *testing.T) { func TestMergeОтказНеОставляетЧастиОбъектов(t *testing.T) {
t.Parallel() t.Parallel()
st := open(t) st := open(t)
@@ -218,7 +218,7 @@ func TestMergePointsОтказНеОставляетЧастиОбъектов(t
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()
if _, err := st.MergePoints(ctx, in, "d1"); err == nil { if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err == nil {
t.Fatal("слияние на отменённом контексте прошло успешно") t.Fatal("слияние на отменённом контексте прошло успешно")
} }
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-02
@@ -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 строкам.
@@ -0,0 +1,80 @@
## Why
Половина живого потока разбором не покрыта. Замер по 118 доставкам архива:
65 несут `metrics`, 27 — `workouts`, 26 — `stateOfMind`. Тренировки и состояние
разума сохраняются в архив и числятся `partial`, но в витрину не попадают:
трекеру (второй потребитель паспорта) взять тренировку неоткуда, агенту-медику
состояние разума — тоже.
Для `stateOfMind` это дороже, чем для метрик: в родном экспорте Apple секции нет
ни одним типом (находка 46), то есть доставки HAE — её **единственный** источник,
и ретеншен архива без разобранной секции нельзя включать вовсе.
## What Changes
- Разбор покрывает две новые секции тела: `workouts` и `stateOfMind`. Остальные
секции с собственными `id` (`ecg`, `symptoms`, `cycleTracking`, `medications`,
`heartRateNotifications`) остаются непокрытыми намеренно — живьём поток их не
приносил ни разу, и модель под них закладывается, а разбор — нет.
- Две новые таблицы: `workout` (заголовок колонками, всё остальное, включая
маршрут и внутренние ряды, — `payload` дословно) и `record` (секции с
собственным `id`, ключ `kind + id`).
- Сущность с собственным `id` **заменяется целиком**, а не сливается по полям.
Правило замены названо явно: приехавшая версия побеждает, если не теряет
содержания сохранённой; иначе сохранённая остаётся, факт считается и идёт в
`WARN`. При равных наборах полей выигрывает версия из более поздней доставки
**журнала**, а не свёрнутая последней, — иначе живая витрина расходилась бы с
пересборкой молча.
- Сущность несёт провенанс — доставку своей версии и её метку приёма.
- Разбор метки времени принимает второй формат — RFC 3339 в UTC, которым HAE шлёт
`stateOfMind` (находка 16).
- Отпечаток витрины (оракул сходимости `reindex`) покрывает тренировки и записи,
а не одни часовые объекты, и снимается одним снимком базы. Отчёт `reindex`
считает «до и после» по каждой единице хранения и называет «покрыта новая
секция» ожидаемым классом расхождения.
- «Отказ разбора — всё или ничего» распространяется на сущности явно, включая
ветку невыводимого слоя и повтор ключа секции.
- Миграция переводит в `pending` доставки, у которых в списке непокрытых секций
стоят ставшие покрытыми имена, — правило «покрыли секцию — пересверните» уже
записано в спеке хранения.
- **Не входит:** отдача тренировок и записей наружу. Read API в проекте пока нет
вовсе; его форма (конверт ответа, выбор слоя, предел размера) проектируется
задачей `read-api-tochki`, и вводить два эндпоинта раньше конверта значило бы
задать контракт мимоходом.
## Capabilities
### New Capabilities
Новых нет: тренировка и запись — это то же хранилище и тот же разбор, только
другая единица хранения. Отдельная capability создала бы второй словарь для того
же домена.
### Modified Capabilities
- `parsing`: покрытых секций становится три вместо одной; появляется разбор
сущностей с собственным `id` и второй формат метки времени (RFC 3339),
оставленный предыдущей дельтой явно ненормированным; «всё или ничего»
распространяется на сущности.
- `storage`: появляется вторая единица хранения — сущность с собственным `id`, со
своим правилом замены версии; отпечаток витрины перестаёт быть отпечатком одних
часовых объектов; запрет на данные о здоровье в логах распространяется на
содержимое сущностей.
- `reindex`: счётчики отчёта покрывают все единицы хранения, а «покрыта новая
секция» становится названным классом ожидаемого расхождения.
## Impact
- `internal/hae` — разбор двух секций, второй формат метки, новые счётчики
пропусков.
- `internal/store` — таблицы `workout` и `record`, слияние доставки одной
транзакцией вместе с точками, отпечаток витрины.
- `internal/fold` — счётчики и единственный логирующий чекпоинт свёртки.
- `internal/replay` — ничего, кроме того, что отпечаток стал шире: пересборка
зовёт ту же свёртку.
- Миграция `00007` — две таблицы и перевод `partial`-доставок в `pending`.
- `docs/database.md`, `docs/architecture.md`, `docs/local-research.md` — схема,
правило замены версии и находка о поведении тренировки при переприсылке.
- Фикстуры `internal/hae/testdata` и скрипт их сборки `tmp/research/fixtures.py`:
тренировка с маршрутом и состояние разума — на реальных пакетах с вычищенными
измерениями.
@@ -0,0 +1,324 @@
## ADDED Requirements
### Requirement: Разбор секций с собственными идентификаторами
Система SHALL разбирать секции тела, элементы которых несут собственный `id`, в
**сущности**, а не в точки: у сущности нет ни слоя, ни координатного ключа
`метрика + слой + начало + конец` — её адресует сам `id`.
Покрываются две такие секции: `data.workouts` и `data.stateOfMind`. Секции
`ecg`, `symptoms`, `cycleTracking`, `medications` и `heartRateNotifications`
покрытыми MUST NOT становиться: живой поток не приносил их ни разу (118
доставок), их форма никем не наблюдалась, а полнота покрытия HealthKit ради
полноты целью проекта не является. Они остаются в списке непокрытых, и доставка
с ними остаётся `partial`.
Из тренировки разбор SHALL брать только то, по чему потом идёт выборка:
идентификатор, имя, начало, конец, офсет исходной зоны и длительность. Всё
остальное — включая маршрут, внутренние ряды (`heartRateData`,
`activeEnergy`, `heartRateRecovery`) и сводки — MUST храниться содержимым
сущности **дословно**, теми же байтами, какими пришло. Раскладывать структуру
тренировки по колонкам значило бы решить за Apple, что в ней главное: сводки
дублируют ряды (`distance` — это сумма `walkingAndRunningDistance`), а набор
полей зависит от типа тренировки (у уличной есть `route`, `avgSpeed`,
`flightsClimbed`, у домашней — `temperature`, `humidity`, `intensity`).
Длительность SHALL браться из тела, а не вычисляться из начала и конца: HAE
шлёт `91.746` секунды при интервале в 91 секунду, и вычисленное значение молча
разошлось бы с присланным. Отсутствие или нечисловое значение длительности
сущность MUST NOT отбрасывать; такая длительность SHALL быть выражена
отсутствием значения, а не нулём — ноль является законной длительностью, и
потребитель не отличил бы «источник не прислал» от «измерено ноль».
Началом сущности SHALL быть `start`, при его отсутствии — `date`. Конец берётся
из `end`; при отсутствии или неразбираемости конца он SHALL равняться началу, а
истина остаётся в содержимом. Вырождение интервала здесь безопасно, в отличие от
точки: ключ сущности — `id`, схлопывать координаты нечем. Офсет исходной зоны
SHALL браться из начала: колонка одна, а тренировка через смену зоны дала бы
два разных.
Из записи разбор SHALL брать идентификатор, род секции, метку времени и офсет;
всё остальное хранится дословно. Род записи SHALL быть верхнеуровневым ключом
секции HAE **дословно** (`stateOfMind`, не `state_of_mind`): инвариант «форма
Apple не транслируется» относится и к именам секций.
Длина идентификатора SHALL быть ограничена, и сущность с более длинным `id`
SHALL пропускаться тем же счётчиком, что и сущность без `id`. Идентификатор
приходит из тела, которым отправитель управляет целиком, а уезжает и в ключ
таблицы, и в записи лога; правило то же, что уже действует для имён непокрытых
секций.
Ряд пульса **внутри** тренировки MUST NOT попадать в метрику `heart_rate`:
это разные сущности хранилища. Пульс приезжает дважды — в общем потоке метрик и
внутри тренировки, — и смешение задвоило бы ряд.
Сущность без `id` либо без разбираемой метки времени SHALL пропускаться со
счётчиком, не роняя разбор остального: тело остаётся в архиве, и доставку
подберёт пересборка, когда разбор научится её понимать.
Отсутствие покрытой секции в теле ошибкой быть MUST NOT: доставки из одних
метрик — большинство потока.
#### Scenario: Тренировка разбирается вместе с маршрутом
- **WHEN** тело содержит `data.workouts` с тренировкой, несущей `route`
- **THEN** разбор отдаёт сущность с идентификатором, именем, началом, концом,
офсетом и длительностью
- **AND** её содержимое несёт маршрут и внутренние ряды исходными байтами
#### Scenario: Ряд пульса тренировки не становится метрикой
- **WHEN** тренировка содержит `heartRateData`
- **THEN** точки этого ряда не попадают в точки метрик
- **AND** остаются внутри содержимого сущности
#### Scenario: Запись состояния разума разбирается
- **WHEN** тело содержит `data.stateOfMind` с элементом, несущим `id` и `start`
- **THEN** разбор отдаёт запись с родом `stateOfMind`, идентификатором, меткой
времени и содержимым исходными байтами
#### Scenario: Сущность без идентификатора пропускается
- **WHEN** элемент покрытой секции не несёт `id` либо `id` пуст
- **THEN** сущность в результат разбора не попадает
- **AND** факт учитывается счётчиком, а разбор остальных сущностей продолжается
#### Scenario: Элемент секции не является объектом
- **WHEN** элемент покрытой секции не разбирается как объект JSON
- **THEN** сущность в результат разбора не попадает
- **AND** факт учитывается **отдельным** счётчиком, а соседние сущности
разбираются как обычно
Отдельным, а не общим с «нет `id`»: доставка, где не разобрался сам элемент, —
это сменившаяся форма секции, а доставка без `id` — сменившаяся форма
идентификатора. Ронять из-за такого элемента всю доставку нельзя тем более:
`failed` фоновая свёртка не подбирает никогда, и вместе с одной кривой
тренировкой в него уехали бы записи `stateOfMind` той же доставки.
#### Scenario: Сущность без разбираемой метки времени пропускается
- **WHEN** элемент покрытой секции несёт `id`, но его метка времени не
разбирается ни одним из поддерживаемых форматов
- **THEN** сущность в результат разбора не попадает
- **AND** факт учитывается счётчиком
#### Scenario: Сущность со слишком длинным идентификатором пропускается
- **WHEN** элемент покрытой секции несёт `id` длиннее предела
- **THEN** сущность в результат разбора не попадает
- **AND** факт учитывается тем же счётчиком, что и отсутствие `id`
#### Scenario: Длительность берётся из тела, а не из интервала
- **WHEN** тренировка несёт `duration` равный `91.746` при интервале
`start`/`end` в 91 секунду
- **THEN** длительность сущности равна `91.746`
#### Scenario: Тренировка без длительности сохраняется без неё
- **WHEN** тренировка не несёт `duration` либо оно не является числом
- **THEN** сущность сохраняется, а её длительность остаётся незаполненной
- **AND** нулём она MUST NOT становиться
#### Scenario: Нечитаемый конец тренировки не отбрасывает её
- **WHEN** тренировка несёт `end`, который не разбирается
- **THEN** конец сущности равен её началу
- **AND** исходное значение остаётся в содержимом дословно
#### Scenario: Незнакомое поле тренировки переживает разбор
- **WHEN** тренировка несёт поле, которого разбор не знает
- **THEN** оно сохраняется в содержимом сущности дословно
- **AND** разбор не завершается ошибкой
#### Scenario: Непокрытая секция с собственными id остаётся непокрытой
- **WHEN** тело содержит `data.ecg`
- **THEN** `ecg` попадает в список непокрытых ключей
- **AND** сущностей из неё разбор не отдаёт
## MODIFIED Requirements
### Requirement: Отказ разбора остаётся всё или ничего
Разбор SHALL оставаться операцией «всё или ничего»: ошибка, встреченная
**после** того, как покрытая секция уже разобрана (обрезанное тело, мусор в
следующем члене), MUST NOT оставлять в результате ни точек, ни сущностей —
доставка считается неразобранной целиком.
Иначе часть данных оказалась бы в витрине под статусом, по которому доставку
никто не подберёт, и свёртка перестала бы быть детерминированной по журналу.
Правило SHALL распространяться и на невыводимый слой: доставка, у которой есть
метрики, но слой их не определяется, не сохраняет и своих сущностей, хотя слоя
у сущности нет. Соблазн «сущности от слоя не зависят, запишем их» ломает то же
«всё или ничего» — доставка получила бы `failed` при частично записанной
витрине, и повторная свёртка перестала бы быть no-op. Цена названа вслух: если
такая доставка когда-нибудь принесёт `stateOfMind`, его записи доедут не сразу,
а пересборкой; тело при этом остаётся в архиве, и `failed` ретеншену трогать
нельзя.
Повтор ключа покрытой секции в одном объекте `data` SHALL давать объединение
секций, а не победу последней: молча терять данные нельзя. То же SHALL
относиться к повтору самого члена `data` в теле — результаты **накапливаются**,
включая список непокрытых ключей.
#### Scenario: Тело оборвано после секции метрик
- **WHEN** тело содержит целую секцию `metrics`, а следующий член `data`
оборван
- **THEN** разбор завершается ошибкой и точек не отдаёт
#### Scenario: Тело оборвано после секции тренировок
- **WHEN** тело содержит целую секцию `workouts`, а следующий член `data`
оборван
- **THEN** разбор завершается ошибкой и сущностей не отдаёт
#### Scenario: Невыводимый слой не сохраняет и сущностей
- **WHEN** доставка несёт метрики, слой которых не определяется, и вместе с
ними секцию `stateOfMind`
- **THEN** разбор завершается ошибкой, ни точек, ни записей не отдаёт
- **AND** список непокрытых ключей переживает отказ
#### Scenario: Секция метрик встречается дважды
- **WHEN** объект `data` содержит два ключа `metrics`
- **THEN** точки обеих секций попадают в результат
#### Scenario: Секция тренировок встречается дважды
- **WHEN** объект `data` содержит два ключа `workouts`
- **THEN** сущности обеих секций попадают в результат
#### Scenario: Член `data` встречается дважды
- **WHEN** тело содержит два члена `data`, из которых первый несёт непокрытую
секцию, а второй — покрытую
- **THEN** данные покрытой секции попадают в результат
- **AND** имя непокрытой секции остаётся в списке непокрытых ключей
### Requirement: Разбор форматов времени
Система SHALL разбирать метку формата `2026-07-31 21:03:51 +0300` и приводить
её к UTC, сохраняя офсет исходной зоны. В секции `data.metrics` других форматов
меток не встречается.
Система SHALL разбирать вторым форматом RFC 3339 в UTC
(`2026-07-31T18:03:51Z`): им приходят метки секции `data.stateOfMind`, тогда
как тренировки и метрики шлют первый формат. Оба формата SHALL приниматься **у
любой** метки сущности, а не приписываться секции жёстко: формы однозначны и не
пересекаются, а HAE выравнивает секции между собой по ходу своих обновлений —
`stateOfMind` уже шлёт стабильные коды HealthKit там, где старые секции шлют
переводы. Приписанный секции формат ломался бы молча в день такого выравнивания.
Метка RFC 3339 в UTC даёт офсет `0`, и это MUST означать «источник прислал
UTC», а не «человек находился в нулевой зоне»: местной зоны у секции
`stateOfMind` в потоке нет вовсе.
Метка **точки** при этом остаётся строгой — один формат, — и асимметрия
намеренная. По метке точки выводится слой, причём по метке в **исходной зоне**;
терпимость к RFC 3339 означала бы, что метка в UTC тихо портит выравнивание и
часовая выгрузка складывается с минутной (наблюдалось: удвоение суммы за час).
У сущности слоя нет, и терять на строгости нечего, а у точки строгий парсер
отдаёт непонятую метку в счётчик пропусков — тело остаётся в архиве, и
пересборка вернёт его, когда формат станет известен.
Unix-эпоха дробным числом (`1785446196.4132624`) встречается **внутри**
`heartbeatSeries` и меткой точки не является. Система MUST NOT преобразовывать
её: элементы серии проходят как исходные байты. Преобразование во `time.Unix`
и обратно не гарантирует дословности, а серия составляет 93% объёма метрики
`heart_rate_variability`.
Время внутри маршрута тренировки (`route[].timestamp`) меткой сущности тоже не
является и MUST проходить дословно, не разбираясь.
#### Scenario: Локальное время со смещением
- **WHEN** метка имеет вид `2026-07-31 21:03:51 +0300`
- **THEN** точка получает время в UTC и офсет `+10800` секунд
#### Scenario: RFC 3339 в UTC
- **WHEN** метка сущности имеет вид `2026-07-31T18:03:51Z`
- **THEN** сущность получает время в UTC и офсет `0`
#### Scenario: Тренировка со временем в формате метрик
- **WHEN** тренировка несёт `start` вида `2026-08-01 10:04:31 +0300`
- **THEN** сущность получает время в UTC и офсет `+10800` секунд
#### Scenario: Время внутри серии ударов
- **WHEN** точка метрики `heart_rate_variability` содержит `heartbeatSeries`
- **THEN** элементы серии сохраняются исходными байтами вместе с их эпохой
- **AND** серия не разворачивается в отдельные точки
- **AND** эпоха внутри серии не разбирается и не преобразуется
#### Scenario: Время внутри маршрута не разбирается
- **WHEN** тренировка содержит `route` с полем `timestamp` у каждой точки
- **THEN** точки маршрута сохраняются исходными байтами
- **AND** их метки не разбираются и не преобразуются
### Requirement: Перечисление непокрытых секций доставки
Разбор SHALL перечислять верхнеуровневые ключи объекта `data` и возвращать
вызывающему те из них, которые он не покрывает. Содержимое непокрытой секции
MUST NOT удерживаться после того, как разбор прошёл мимо неё: тела доходят до
42 МиБ, и удержание кучи здесь — часть контракта, а не деталь реализации.
Покрытых ключей сегодня три — `metrics`, `workouts` и `stateOfMind`. Разбор и
перечисление MUST ходить по одному объявленному множеству покрытых имён:
состояние «секция разбирается, но числится непокрытой» невыразимо по построению.
Непокрытым ключ считается независимо от того, что лежит внутри: содержимое не
интерпретируется, поэтому и о пустоте секции разбор честно ничего не знает.
Измерено на живом архиве — пустых секций HAE не присылает ни разу (118 доставок).
Список SHALL быть каноничен: имена отсортированы, повторов нет. Порядок ключей в
JSON от HAE нестабилен, а значение уезжает в базу и сравнивается между
доставками.
Отсутствие непокрытых ключей и отсутствие секции `metrics` — разные события, и
оба нормальны: половина потока состоит из доставок без метрик вовсе (53 из 118).
#### Scenario: Незнакомая секция попадает в список непокрытых
- **WHEN** тело содержит `data.ecg` наряду с `data.metrics`
- **THEN** разбор возвращает `ecg` в списке непокрытых ключей
- **AND** точки секции `metrics` разбираются как обычно
#### Scenario: Доставка из одних тренировок непокрытых ключей не даёт
- **WHEN** тело содержит только `data.workouts`
- **THEN** разбор завершается без ошибки, точек нет, тренировки разобраны
- **AND** список непокрытых ключей пуст
#### Scenario: Доставка из одного состояния разума непокрытых ключей не даёт
- **WHEN** тело содержит только `data.stateOfMind`
- **THEN** разбор завершается без ошибки, записи разобраны
- **AND** список непокрытых ключей пуст
#### Scenario: Доставка из одних метрик непокрытых ключей не даёт
- **WHEN** единственный ключ `data``metrics`
- **THEN** список непокрытых ключей пуст
#### Scenario: Один и тот же набор секций даёт один и тот же список
- **WHEN** два тела несут те же секции в разном порядке, а одно из них
повторяет непокрытый ключ дважды
- **THEN** списки непокрытых ключей у них совпадают
#### Scenario: Содержимое непокрытой секции не удерживается в памяти
- **WHEN** тело в десятки мегабайт состоит преимущественно из непокрытой секции
- **THEN** после разбора удержано не больше четырёх размеров тела — та же
граница, что и для тела из метрик
- **AND** содержимое непокрытой секции в результат разбора не попадает
@@ -0,0 +1,133 @@
## MODIFIED Requirements
### Requirement: Отчёт, оракул и исход команды
Система SHALL завершать пересборку отчётом, который несёт счётчики
(проиграно, свёрнуто, отказов по классам, тел без учётной записи, строк без
тела, пропущенных файлов, повторов, объектов **до и после**) и **два
отпечатка** — рабочей витрины и пересобранной, — с прямым ответом, совпали они
или нет.
Счётчики «до и после» SHALL покрывать **каждую единицу хранения витрины**:
часовые объекты, тренировки и записи. Отпечаток отвечает «да/нет» за витрину
целиком, поэтому единица, которой нет в счётчиках, делает расхождение
безадресным: человек увидит «не совпало» при неизменившемся числе объектов и не
отличит появление двадцати семи тренировок от пропажи двух.
Отказы SHALL считаться **по классам**: слой не выводится, содержимое не
разбирается, работа отложена по обстоятельствам, всё прочее. Невыведенный слой
есть в каждом журнале и штатен; общий счётчик отправлял бы человека искать
дефект там, где его нет. Отдельно называть человеку следует только нештатные
отказы.
Отложенная доставка (занятость базы, отмена работы снаружи) SHALL считаться
нештатной **для пересборки**, хотя для фоновой свёртки она штатна: пересборка
идёт в свежий файл при единственном писателе, и такая доставка в собранной
витрине просто отсутствует — вместе с теми, кто наследовал от неё слой. Классы
при этом общие с фоновой свёрткой: второй классификатор разошёлся бы с первым
молча.
Число объектов «было и стало» SHALL печататься рядом с отпечатками: отпечатки
отвечают «да/нет», а решение о подмене необратимо, и по «да/нет» нельзя
судить о **направлении** расхождения. Именно пара чисел — 1737 против 1742 —
поймала прошлый дефект наследования слоя.
Отпечаток здесь оракул, а не украшение: число объектов к правилу разрешения
столкновений нечувствительно — на координате всегда ровно одна точка, и правило
выбирает, какая, а не сколько. «Объектов столько же» совпало бы и при заведомо
сломанном правиле.
Отпечаток рабочей витрины SHALL сниматься **до** начала проигрывания, а число
доставок в рабочей базе — до и после. Ненулевая разница SHALL называться в
отчёте, и при ней процедура подмены печататься MUST NOT: доставки, приехавшие за
время прогона, есть в рабочей базе и в архиве, но не в собранном файле, и
подмена стёрла бы их учёт вместе с заголовками, которых в архиве нет.
Величины, которые не снимались, отчёт печатать MUST NOT. При отмене отпечаток
пересобранной витрины и число доставок после прогона не измеряются вовсе —
печатать их сравнение значило бы выдать неизмеренное за измеренное, причём в
единственном оракуле задачи. Ожидаемые классы расхождения (новые доставки за время прогона,
непереносимый признак запечатанного часа, исправленный разбор, **покрытая
разбором новая секция**) SHALL называться отдельно от самого факта расхождения.
Класс «покрыта новая секция» назван потому, что первый прогон после такого
изменения расходится **гарантированно** и штатно: витрина обзаводится единицами
хранения, которых в рабочей базе нет по построению. Не назвав его, отчёт
приучает человека игнорировать расхождение отпечатков — то есть обесценивает
оракул ровно тогда, когда по нему принимается необратимое решение.
**Исход команды.** Расхождение отпечатков отказом быть MUST NOT: после
исправления разбора оно ожидаемо и есть сам смысл пересборки. Отказ отдельной
доставки отказом команды тоже MUST NOT быть: доставка, слой которой не
выводится, — штатный исход.
Отказом команды SHALL быть: пустой журнал, отсутствие хотя бы одной свёрнутой
доставки, отмена и любая ошибка окружения. Пустая витрина совпадает по
отпечатку с пустой витриной, поэтому прогон по пустому журналу выглядит
идеальной сходимостью — а все умолчания подыгрывают такому запуску: конфига
может не быть вовсе, и тогда пути указывают в рабочий каталог процесса. Человек,
выполнивший напечатанную процедуру, заменил бы витрину пустой.
Отчёт значений точек, имён метрик, имён устройств и содержимого тел содержать
MUST NOT: отпечаток берёт содержимое хешем. Ограничение относится к отчёту в
стандартном выводе; лог свёртки живёт по правилам спеки хранения, где координаты
столкновения (метрика, слой, час) разрешены явно.
Отчёт идёт в стандартный вывод человеческим текстом. Прогресс длинного прогона
SHALL идти в поток ошибок, а не смешиваться с отчётом: прогон на полном архиве
молчит минутами, и зависший неотличим от идущего.
#### Scenario: Отчёт сравнивает отпечатки
- **WHEN** пересборка завершилась
- **THEN** отчёт содержит отпечаток рабочей витрины и отпечаток пересобранной
- **AND** прямо называет, совпали они или нет
- **AND** называет, изменилось ли число доставок в рабочей базе за время прогона
#### Scenario: Счётчики покрывают все единицы хранения
- **WHEN** пересборка завершилась
- **THEN** отчёт печатает «до и после» отдельно для часовых объектов,
тренировок и записей
#### Scenario: Расхождение отпечатков не является отказом
- **WHEN** отпечаток пересобранной витрины отличается от рабочей, и при этом
хотя бы одна доставка свёрнута
- **THEN** команда завершается успешно, а расхождение названо в отчёте
#### Scenario: Пустой журнал — отказ, а не идеальная сходимость
- **WHEN** в архиве не нашлось ни одного тела
- **THEN** команда завершается ненулевым кодом
- **AND** процедуры подмены не печатает
#### Scenario: Ни одна доставка не свернулась
- **WHEN** журнал непуст, но свернуть не удалось ни одной доставки
- **THEN** команда завершается ненулевым кодом
- **AND** процедуры подмены не печатает
#### Scenario: Приезд доставок за время прогона отменяет подмену
- **WHEN** число доставок в рабочей базе за время прогона изменилось
- **THEN** отчёт называет разницу
- **AND** процедуры подмены не печатает
#### Scenario: Отчёт после отмены не сравнивает неизмеренного
- **WHEN** прогон отменён
- **THEN** отчёт не содержит ни ответа о совпадении отпечатков, ни разницы
числа доставок
#### Scenario: Рабочей базы нет вовсе
- **WHEN** файла рабочей базы не существует
- **THEN** пересборка идёт по одним подобранным телам
- **AND** отчёт называет, что сверять не с чем и что заголовки доставок не
восстанавливаются
#### Scenario: Отчёт не раскрывает данных о здоровье
- **WHEN** отчёт напечатан
- **THEN** он не содержит ни значений точек, ни имён метрик, ни имён устройств
@@ -0,0 +1,320 @@
## ADDED Requirements
### Requirement: Хранение сущностей с собственным идентификатором
Система SHALL хранить тренировки и записи секций с собственным `id` **не**
часовыми объектами, а по одной строке на сущность: у них есть естественный
ключ, они редки (за двое суток потока — две тренировки и две записи состояния
разума), и группировать их по часам незачем.
Единиц хранения две:
```
тренировка ключ id
заголовок колонками: имя, начало, конец, офсет зоны, длительность
запись ключ род секции + id
заголовок колонками: род, метка времени, офсет зоны
```
Сущность SHALL нести **провенанс** — идентификатор доставки, чья версия лежит
сейчас, и метку приёма этой доставки. Он нужен не отчётности: по нему
разрешается тай-брейк между версиями равной полноты (см. «Замена версии
сущности…»), и без него `WARN` об удержанной обеднённой версии не связать с
телом в архиве.
Длительность тренировки SHALL допускать отсутствие значения, отличимое от нуля:
ноль — законная длительность, и потребитель, сложивший столбец, иначе не отличил
бы «источник не прислал» от «измерено ноль».
Ключ записи SHALL быть парой `род + id`, а не одним `id`. Собственный `id`
наблюдался живьём только у `stateOfMind`, где он UUID HealthKit; форма
идентификатора остальных пяти секций не наблюдалась никем, и короткий
несквозной `id` в двух разных секциях затёр бы одну запись другой молча. Пара
стоит ноль: запросы к записям всегда идут с родом.
Содержимое сущности SHALL храниться **дословно** — теми же байтами, какими
пришло, включая маршрут, внутренние ряды и сводки. Заголовок колонками
существует ради выборки по времени и не является разбором содержимого: любая
следующая колонка была бы решением за Apple о том, что в тренировке главное.
Ряд пульса внутри тренировки MUST лежать в её содержимом, а не в объектах
метрики `heart_rate`: это разные таблицы, и смешение задвоило бы ряд.
Сущности доставки SHALL записываться **той же транзакцией**, что и её точки.
Доставка — единица свёртки; частичное состояние ломает инвариант «состояние
пересобираемо», а наблюдение «секции не смешиваются в одной доставке» собрано
за двое суток и основанием для второй транзакции не является.
Система SHALL хранить рядом с сущностью хеш её канонического содержимого и
пропускать запись, если хеш не изменился. Тренировка переприсылается каждой
доставкой автоматизации, пока не доедет маршрут: на живом архиве 44 доставленные
копии дают три различных содержимых.
Сравнение SHALL начинаться с хеша, читаемого **без** содержимого сохранённой
сущности: маршрут доходит до мегабайта, разжимать и канонизировать его на каждой
из 44 копий не за чем. Хеш приехавшей сущности SHALL считаться один раз на
доставку, а не на каждой попытке повтора транзакции при занятости базы:
канонизация материализует значение целиком, и повтор умножал бы пик кучи.
#### Scenario: Тренировка хранится одной строкой с маршрутом
- **WHEN** приезжает тренировка с маршрутом
- **THEN** она хранится одной строкой, адресуемой своим `id`
- **AND** маршрут и внутренние ряды лежат в её содержимом дословно
#### Scenario: Ряд пульса тренировки не попадает в метрику
- **WHEN** тренировка несёт `heartRateData`
- **THEN** объектов метрики `heart_rate` эта доставка не создаёт
#### Scenario: Записи разных родов с одинаковым id не сталкиваются
- **WHEN** две записи разных родов приезжают с одним и тем же `id`
- **THEN** в хранилище лежат обе
#### Scenario: Повторная присылка той же тренировки не пишет в базу
- **WHEN** приезжает тренировка, содержимое которой совпадает с сохранённым
- **THEN** хеш совпадает и запись не выполняется
#### Scenario: Отказ посреди доставки не оставляет части сущностей
- **WHEN** свёртка доставки прерывается на середине
- **THEN** не записывается ни одна сущность этой доставки
### Requirement: Замена версии сущности не теряет содержания
Сущность с собственным `id` SHALL замещаться **целиком**, а не сливаться по
полям: она приезжает повторно, пока источник её досчитывает. Замер на живом
архиве: одна тренировка приехала 26 раз в трёх различных содержимых — сперва
добавились `stepCadence` и `stepCount` вместе с изменившимся рядом
`activeEnergy`, затем при том же наборе полей досчитались `totalEnergy` и
`basalEnergy`.
Замещение MUST быть условным: приехавшая версия побеждает, **если не теряет
содержания** сохранённой. Порядок разбора:
```
1. хеш канонического содержимого совпал → записи нет
2. содержание приехавшей покрывает сохранённую
и сверх того → приехавшая замещает целиком
3. приехавшая теряет содержание сохранённой → остаётся сохранённая,
счётчик + WARN
4. содержание сравнимо, наборы равны → версия из более поздней
доставки журнала
5. наборы несравнимы → остаётся сохранённая,
счётчик + WARN
```
**Содержание сравнивается множеством ключей с непустым значением — и только им.**
Сравнение полноты, принятое для точек, здесь неприменимо: оно гасит отношение
включения, когда значения общих содержательных ключей разошлись, а у сущности
они расходятся **всегда** — источник её досчитывает. Проверено: сохранённая
тренировка с маршрутом против приехавшей без маршрута даёт «надмножество» при
неизменных значениях и «равенство» при изменившихся, то есть на живых данных
защита не сработала бы вовсе, а тест на фикстуре с неизменёнными значениями
остался бы зелёным. Условия «значения общих ключей совпали» здесь быть MUST NOT.
Дополнительно к множеству ключей SHALL сравниваться **длина верхнеуровневых
массивов**: усечённый маршрут (три точки вместо 593) ключа не теряет, а теряет
95% содержимого тренировки. Досчёт ряды удлиняет, поэтому укорачивание —
законный признак «приехало меньше». Предел правила называется вслух: сокращение
**внутри** элемента ряда (точка маршрута без `altitude`) не ловится ничем, кроме
сверки с телом в архиве.
Единственная причина повторной присылки — доезжающий маршрут, то есть рост:
обратного за 44 доставленные копии не случилось ни разу. Но восстановление
требует пересборки всего журнала, поэтому событие делается наблюдаемым, а не
необратимым.
**Тай-брейк при равных наборах — позиция доставки в журнале `(received_at, id)`,
а не порядок свёртки.** «Побеждает приехавшая» было бы функцией порядка
свёртки, а он порядку журнала не равен: воркер сворачивает в порядке журнала
только среди видимых ему доставок и абсолютного порядка при конкурентных
приёмах не обещает. Доставка с более ранней меткой, свёрнутая позже, вернула бы
витрину к недосчитанной версии, и пересборка разошлась бы с живым приёмом молча,
в содержимом тренировки. Позиция журнала снимает это: исход зависит от журнала,
а не от того, кто раньше добрался до базы.
Отличие от точки здесь содержательное: у точки на одних координатах законно
встречаются два разных измерения, и предпочитать позднее нет оснований — там
исход решает порядок канонических форм. У сущности `id` — идентичность одного
объекта HealthKit, и вторая версия есть тот же объект, пересчитанный источником;
тай-брейк по канонической форме заморозил бы тренировку на произвольной из
версий навсегда, вместе с недосчитанной энергией.
Две версии одного ключа **внутри одной доставки** позициями не различаются и
SHALL разрешаться минимумом канонической формы — включая случай несравнимых
наборов. Внутри доставки «сохранённой» версии не существует, есть только
порядок элементов в JSON-массиве, а он нестабилен: правило «остаётся первая
встреченная» сделало бы исход функцией порядка на проводе. Сворачиваться между
собой такие версии SHALL до сравнения с сохранённой, а факт «в одном теле
приехали две версии одного ключа с разным содержанием» SHALL считаться
**симметрично**: счётчик, зависящий от порядка элементов, наблюдал бы событие
через раз.
Поля версий MUST NOT объединяться: несравнимые наборы (приехавшая принесла
новые ключи и потеряла старые) разрешаются в пользу сохранённой и считаются
тем же счётчиком. Объединение отвергнуто там же и по той же причине, что для
точек: на живом потоке событие не наступало, и вместо реализации заведено
наблюдение.
Исход SHALL быть функцией журнала в его порядке. Остаточный предел называется
вслух: слияние попарное — сохранённая против приехавшей, — поэтому при
несравнимых наборах (пункт 5) исход зависит от порядка проигрывания. Тот же
предел есть у часового объекта, где хранится победитель прошлых слияний, а не
все кандидаты истории; пункты 2–4 от порядка свёртки не зависят, а пункт 5
сопровождается счётчиком и `WARN`.
#### Scenario: Доехавший маршрут замещает тренировку без маршрута
- **WHEN** та же тренировка приезжает повторно, добавив `route`
- **THEN** в хранилище лежит версия с маршрутом
#### Scenario: Досчитанные значения при том же наборе полей побеждают
- **WHEN** та же тренировка приезжает повторно с тем же набором полей и
изменившимися значениями, доставкой с более поздней позицией журнала
- **THEN** в хранилище лежит приехавшая версия
#### Scenario: Версия из более ранней доставки не откатывает витрину
- **WHEN** две доставки несут одну тренировку с равными наборами полей, и
свёрнута сперва более поздняя по журналу, затем более ранняя
- **THEN** в хранилище лежит версия из более поздней доставки
- **AND** тот же исход даёт свёртка в обратном порядке
#### Scenario: Обеднённая версия сохранённую не затирает
- **WHEN** та же тренировка приезжает повторно **без** `route`, который был у
сохранённой, **и** с изменившимися значениями общих полей
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается счётчиком и записью `WARN` с идентификатором
тренировки
#### Scenario: Усечённый маршрут сохранённый не затирает
- **WHEN** та же тренировка приезжает повторно с тем же набором полей, но
`route` короче сохранённого
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается тем же счётчиком
#### Scenario: Две версии одной сущности в одном теле
- **WHEN** тело содержит два элемента секции с одним `id`
- **THEN** исход не зависит от их порядка в массиве
- **AND** счётчик различающихся версий тоже не зависит от их порядка
#### Scenario: Составной ключ не даёт коллизии отпечатка
- **WHEN** две витрины различаются только тем, где проходит граница между родом
и идентификатором записи
- **THEN** отпечатки не совпадают
#### Scenario: Несравнимые наборы полей не объединяются
- **WHEN** приехавшая версия несёт содержательный ключ, которого нет у
сохранённой, и теряет содержательный ключ, который у сохранённой есть
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается тем же счётчиком
#### Scenario: Повторная свёртка того же журнала состояния не меняет
- **WHEN** те же доставки сворачиваются повторно в том же порядке
- **THEN** содержимое сущностей не меняется
### Requirement: Отпечаток витрины покрывает все её сущности
Отпечаток витрины SHALL включать тренировки и записи наравне с часовыми
объектами: он единственный оракул сходимости пересборки, и отпечаток одних
объектов давал бы «состояние сошлось» при разъехавшихся тренировках — то есть
ломался бы молча ровно тем изменением, которое добавляет данные.
В отпечаток идут координаты сущности и хеш её содержимого. Значений он
раскрывать MUST NOT — как и для точек.
Порядок обхода SHALL быть детерминированным и заданным запросом, а не порядком
строк в файле базы. Строки разных разделов SHALL различаться константным
признаком раздела: без него строка одного раздела может совпасть со строкой
другого, и два разных состояния дали бы один отпечаток. По той же причине
составной ключ SHALL идти в отпечаток **отдельными полями с собственными
длинами**, а не склейкой: склейка выполняется до взятия длины, и пара
(`a`, `b/c`) даёт ту же строку, что (`a/b`, `c`).
Границу оракула стоит назвать вслух: в отпечаток идут координаты и хеш
содержимого, а **колонки заголовка** сущности (имя, конец интервала, офсет,
длительность) — нет. Они производны от содержимого, поэтому их расхождение
означает изменившийся код извлечения заголовка, а не разъехавшееся состояние; но
«отпечатки совпали» не является утверждением о них.
Все разделы SHALL читаться **одним снимком** базы. Отпечаток рабочей витрины
снимается под живым приёмом, и запросы вне общей транзакции чтения дали бы смесь
«объекты до» и «тренировки после» — то есть ложное расхождение у единственного
оракула сходимости.
#### Scenario: Расхождение тренировок видно в отпечатке
- **WHEN** две витрины совпадают по часовым объектам, но содержимое одной
тренировки различается
- **THEN** отпечатки не совпадают
#### Scenario: Отпечаток одинаков при одинаковом содержимом
- **WHEN** та же витрина собрана повторно из того же журнала
- **THEN** отпечаток совпадает
#### Scenario: Запись во время снятия отпечатка не смешивает разделы
- **WHEN** отпечаток снимается, а параллельно коммитится свёртка
- **THEN** отпечаток отражает одно состояние базы, а не смесь снимков
## MODIFIED Requirements
### Requirement: Значения точек не попадают в логи
Данные о здоровье чувствительнее токенов. Система MUST NOT писать значения
точек, содержимое сущностей и тела доставок в записи лога уровня выше `DEBUG`.
Содержимое сущности здесь не менее чувствительно, чем значение точки, а местами
более: маршрут тренировки — это геотрек до дома, а `labels` и `associations`
записи состояния разума — эмоциональные метки. Разрешены **координаты**:
идентификатор и род сущности, метка времени, идентификатор доставки — они
описывают, что случилось, а не что измерено.
Непокрытые секции называются в логе **именами ключей**: имя секции — это форма
пакета, а не измерение. Содержимое секции в лог не попадает ни при каком уровне
выше `DEBUG`. Имена идут структурным атрибутом, а не склейкой в текст сообщения:
кодировщик экранирует управляющие символы, и имя из чужого тела не разрывает
построчный разбор логов. То же относится к идентификатору сущности: он приходит
из чужого тела и ограничен по длине при разборе.
Частичный разбор уровня записи не повышает: `partial` — установившееся состояние
половины потока (53 доставки из 118), и постоянный `WARN` обесценил бы уровень.
Повышает уровень другое — срабатывание границ списка: тело с сотнями секций или
с именем длиннее предела на HAE не похоже вовсе.
#### Scenario: Разбор доставки логируется без значений
- **WHEN** доставка разобрана
- **THEN** запись лога содержит счётчики (метрик, точек, объектов, сущностей) и
идентификатор доставки
- **AND** не содержит ни значений точек, ни имён устройств
#### Scenario: Удержанная обеднённая версия логируется координатами
- **WHEN** приехавшая версия сущности отклонена как теряющая содержание
- **THEN** запись `WARN` содержит идентификатор и род сущности
- **AND** не содержит ни точек маршрута, ни того, какие поля потерялись
#### Scenario: Непокрытые секции названы именами ключей
- **WHEN** доставка содержит непокрытую секцию
- **THEN** запись лога содержит имена непокрытых ключей отдельным атрибутом
- **AND** не содержит ничего из содержимого этих секций
- **AND** уровень записи из-за одной лишь частичности не повышается
#### Scenario: Границы списка сработали
- **WHEN** список непокрытых ключей усечён по числу имён или по длине имени
- **THEN** запись лога имеет уровень `WARN`
- **AND** содержит число отброшенных имён
@@ -0,0 +1,126 @@
## 1. Фикстуры на реальных пакетах
- [x] 1.1 Расширить `tmp/research/fixtures.py`: вычистка UUID (`id`), меток
RFC 3339, `route[].timestamp` и словарных значений `stateOfMind`
(`kind`, `valenceClassification`, `labels`, `associations`) при
сохранении формы литерала, структуры и порядка ключей
- [x] 1.2 Собрать `internal/hae/testdata/workout_route.json` — уличная
тренировка с маршрутом (проверяет дословность маршрута и внутренних рядов)
- [x] 1.3 Собрать `internal/hae/testdata/workout_indoor.json` — тренировка без
маршрута, с полями, которых нет у уличной (`temperature`, `humidity`,
`intensity`)
- [x] 1.4 Собрать `internal/hae/testdata/state_of_mind.json` — состояние разума
(RFC 3339, отсутствие `source`)
- [x] 1.5 Дописать в `handmade_edge.json` случаи, которых поток не даёт:
сущность без `id`, с пустым и со слишком длинным `id`, с неразбираемой
меткой, с неразбираемым `end`, с нечисловой длительностью, два элемента с
одним `id` в одном теле
- [x] 1.6 Обновить `internal/hae/testdata/README.md`: новые файлы и что именно
вычищено
## 2. Разбор (`internal/hae`)
- [x] 2.1 Множество покрытых секций: `metrics`, `workouts`, `stateOfMind`
одно объявление на разбор и на перечисление непокрытых
- [x] 2.2 Типы `Workout` и `Record` в `Result`; счётчики `SkippedNoID`,
`SkippedNoTime` для сущностей
- [x] 2.3 `parseEntityTime`: формат HAE, затем RFC 3339; офсет из разобранной
зоны. Парсер точек остаётся строгим — причина записана в спеке
- [x] 2.4 Разбор `workouts`: заголовок (`id`, `name`, `start`, `end`,
`duration`), содержимое — исходные байты элемента; `end` нечитаем →
равен началу; длительность отсутствует → не заполнена (не ноль)
- [x] 2.5 Разбор `stateOfMind` в записи рода `stateOfMind` (имя секции дословно)
- [x] 2.6 `Parse` отдаёт сущности и при отсутствии секции `metrics`; при
ошибке (обрыв тела, невыводимый слой) не отдаёт ни точек, ни сущностей
- [x] 2.7 Повтор ключа покрытой секции даёт объединение; повтор члена `data`
**накапливает** результаты, а не присваивает последний (существующий
дефект `decodeEnvelope`)
- [x] 2.8 Предел длины `id`: сущность сверх него пропускается тем же счётчиком
- [x] 2.9 Тесты на фикстурах: маршрут дословно, пульс тренировки не стал
метрикой, оба формата времени, пропуски со счётчиками
- [x] 2.10 Тест: доставка из одних тренировок и из одного `stateOfMind`
непокрытых ключей не даёт; из одного `ecg` — даёт
## 3. Схема (`internal/store/migrations`)
- [x] 3.1 Миграция `00007_workout_record.sql`: таблицы `workout` и `record`
(провенанс `delivery_id` + `delivery_received_at`, `content_hash`,
nullable `duration_sec`), индексы по времени, перевод `partial`-доставок
с ключами `workouts` и `stateOfMind` в `pending`
- [x] 3.2 Проверить, что перевод в `pending` отбирает строки точно (по элементу
JSON-массива, а не по подстроке тела)
- [x] 3.3 Обновить `docs/database.md`: обе таблицы, смысл колонок, ключ
`род + id`, провенанс, `NULL` у длительности, офсет `0` у `stateOfMind`
## 4. Хранение (`internal/store`, `internal/canon`)
- [x] 4.1 `canon`: сравнение множеств содержательных ключей **без** условия о
совпадении значений (поверх существующего `relateKeys`) плюс сравнение
длин верхнеуровневых массивов
- [x] 4.2 `Incoming{Points, Workouts, Records}` и `Merge` вместо `MergePoints`:
одна транзакция на доставку
- [x] 4.3 Правило замены версии: хеш-детектор без чтения блоба, условие «не
теряет содержания», тай-брейк по позиции журнала `(received_at, id)`,
внутридоставочный тай-брейк по канонической форме, счётчик и координаты
для `WARN`
- [x] 4.4 Чтение и запись тренировки и записи; содержимое — сжатый блоб; хеш
приехавшей считается один раз на доставку, до входа в транзакцию
- [x] 4.5 `Fingerprint` покрывает тренировки и записи, читает одной read-only
транзакцией, строки разделов различаются константным признаком
- [x] 4.6 Тесты: замещение маршрутом; досчёт при том же наборе полей; обеднённая
версия **с изменившимися значениями** не затирает; усечённый маршрут не
затирает; несравнимые наборы; повтор не пишет; две версии в одном теле;
перестановка **трёх** версий в двух порядках подачи (конвенция
`docs/conventions.md`)
- [x] 4.7 Тест: отпечаток расходится при расхождении одной тренировки
## 5. Свёртка и пересборка (`internal/fold`, `internal/replay`, `cmd`)
- [x] 5.1 `Stats` несёт счётчики сущностей; свёртка зовёт `Merge` один раз;
счётчики доезжают до лога без ручного копирования (или это покрыто тестом)
- [x] 5.2 Единственный логирующий чекпоинт: атрибуты сущностей, ветка `WARN`
для удержанной обеднённой версии — координатами, без содержимого
- [x] 5.3 Отчёт `reindex`: «до и после» по тренировкам и записям, «покрыта новая
секция» в перечне ожидаемых классов расхождения
- [x] 5.4 Тест: содержимое сущности в лог не попадает
## 6. Сходимость и проверка на живом архиве
- [x] 6.1 `task gate` — зелёный
- [x] 6.2 `task verify:archive` — прогон всего `./data/raw`, повтор даёт то же
состояние; он же оракул того, что живой приём и пересборка применяют к
сущностям один порядок
- [x] 6.3 Проверка на копии рабочей базы в отдельном каталоге данных: миграция
накатывается, `partial`-доставки пересворачиваются, тренировки и записи
появляются
## 7. Документация и беклог
- [x] 7.1 `docs/architecture.md` — раздел «Тренировки и прочие секции»: правило
замены версии с обоснованием, отвергнутые варианты, предел `import`;
**плюс блок схемы БД** (`record(kind, id)`, сжатый блоб, `content_hash`,
провенанс)
- [x] 7.2 `docs/conventions.md` — строка про ключ `record` устарела, поправить
- [x] 7.3 `docs/local-research.md` — находка о поведении тренировки при
переприсылке (числа замера) и пересчёт находки 50 на 118 доставок
- [x] 7.4 Беклог: задача про идентичность тренировок при импорте родного
экспорта (в `export.xml` `id` нет — `dogsheep` считает hash_id);
`retenshen-syrogo-arhiva` — предусловие про `stateOfMind` снова открыто;
отметить в `read-api-tochki`, что отдача тренировок и записей входит в
неё; уточнить `proverka-novyh-sekcij` — модель заложена, остались пять
секций
- [x] 7.5 Удалить `docs/backlog/trenirovki-i-zapisi.md` и строку индекса
## 8. Приёмочные критерии (рубрика ревью)
- [x] 8.1 Тренировка с маршрутом переживает круг «разбор → хранение → чтение»
побайтово
- [x] 8.2 Повторная свёртка того же журнала не меняет отпечатка витрины
- [x] 8.3 Ни один путь не пишет содержимое сущности в лог выше `DEBUG`
- [x] 8.4 Доставка из одних тренировок получает `parsed`, а не `partial`
- [x] 8.5 Доставка с непокрытой секцией по-прежнему `partial`, и её тело
ретеншену трогать нельзя
- [x] 8.6 Исход правила замены не зависит от порядка свёртки в пунктах 2–4
правила; зависимость в пункте 5 наблюдаема счётчиком
- [x] 8.7 У каждого класса пропуска при разборе сущности — свой счётчик, и
пропуск одного элемента не уносит соседей
+239 -23
View File
@@ -159,9 +159,29 @@ Read API «самый мелкий слой, покрывающий диапаз
### Requirement: Разбор форматов времени ### Requirement: Разбор форматов времени
Система SHALL разбирать метку точки формата `2026-07-31 21:03:51 +0300` и Система SHALL разбирать метку формата `2026-07-31 21:03:51 +0300` и приводить
приводить её к UTC, сохраняя офсет исходной зоны. В секции `data.metrics` её к UTC, сохраняя офсет исходной зоны. В секции `data.metrics` других форматов
других форматов меток не встречается. меток не встречается.
Система SHALL разбирать вторым форматом RFC 3339 в UTC
(`2026-07-31T18:03:51Z`): им приходят метки секции `data.stateOfMind`, тогда
как тренировки и метрики шлют первый формат. Оба формата SHALL приниматься **у
любой** метки сущности, а не приписываться секции жёстко: формы однозначны и не
пересекаются, а HAE выравнивает секции между собой по ходу своих обновлений —
`stateOfMind` уже шлёт стабильные коды HealthKit там, где старые секции шлют
переводы. Приписанный секции формат ломался бы молча в день такого выравнивания.
Метка RFC 3339 в UTC даёт офсет `0`, и это MUST означать «источник прислал
UTC», а не «человек находился в нулевой зоне»: местной зоны у секции
`stateOfMind` в потоке нет вовсе.
Метка **точки** при этом остаётся строгой — один формат, — и асимметрия
намеренная. По метке точки выводится слой, причём по метке в **исходной зоне**;
терпимость к RFC 3339 означала бы, что метка в UTC тихо портит выравнивание и
часовая выгрузка складывается с минутной (наблюдалось: удвоение суммы за час).
У сущности слоя нет, и терять на строгости нечего, а у точки строгий парсер
отдаёт непонятую метку в счётчик пропусков — тело остаётся в архиве, и
пересборка вернёт его, когда формат станет известен.
Unix-эпоха дробным числом (`1785446196.4132624`) встречается **внутри** Unix-эпоха дробным числом (`1785446196.4132624`) встречается **внутри**
`heartbeatSeries` и меткой точки не является. Система MUST NOT преобразовывать `heartbeatSeries` и меткой точки не является. Система MUST NOT преобразовывать
@@ -169,16 +189,24 @@ Unix-эпоха дробным числом (`1785446196.4132624`) встреч
и обратно не гарантирует дословности, а серия составляет 93% объёма метрики и обратно не гарантирует дословности, а серия составляет 93% объёма метрики
`heart_rate_variability`. `heart_rate_variability`.
RFC 3339 (`2026-07-31T18:03:51Z`) в этой дельте не нормируется: он встречается Время внутри маршрута тренировки (`route[].timestamp`) меткой сущности тоже не
только в `data.stateOfMind`, которая выведена из scope. Требование к нему является и MUST проходить дословно, не разбираясь.
появится вместе с задачей про секции с собственными `id` — вместе с данными,
на которых его можно проверить.
#### Scenario: Локальное время со смещением #### Scenario: Локальное время со смещением
- **WHEN** метка имеет вид `2026-07-31 21:03:51 +0300` - **WHEN** метка имеет вид `2026-07-31 21:03:51 +0300`
- **THEN** точка получает время в UTC и офсет `+10800` секунд - **THEN** точка получает время в UTC и офсет `+10800` секунд
#### Scenario: RFC 3339 в UTC
- **WHEN** метка сущности имеет вид `2026-07-31T18:03:51Z`
- **THEN** сущность получает время в UTC и офсет `0`
#### Scenario: Тренировка со временем в формате метрик
- **WHEN** тренировка несёт `start` вида `2026-08-01 10:04:31 +0300`
- **THEN** сущность получает время в UTC и офсет `+10800` секунд
#### Scenario: Время внутри серии ударов #### Scenario: Время внутри серии ударов
- **WHEN** точка метрики `heart_rate_variability` содержит `heartbeatSeries` - **WHEN** точка метрики `heart_rate_variability` содержит `heartbeatSeries`
@@ -186,6 +214,12 @@ RFC 3339 (`2026-07-31T18:03:51Z`) в этой дельте не нормируе
- **AND** серия не разворачивается в отдельные точки - **AND** серия не разворачивается в отдельные точки
- **AND** эпоха внутри серии не разбирается и не преобразуется - **AND** эпоха внутри серии не разбирается и не преобразуется
#### Scenario: Время внутри маршрута не разбирается
- **WHEN** тренировка содержит `route` с полем `timestamp` у каждой точки
- **THEN** точки маршрута сохраняются исходными байтами
- **AND** их метки не разбираются и не преобразуются
### Requirement: Разделение схем под одним именем метрики ### Requirement: Разделение схем под одним именем метрики
Система SHALL разводить на разные имена метрики те схемы, которые Health Auto Система SHALL разводить на разные имена метрики те схемы, которые Health Auto
@@ -270,32 +304,38 @@ Export шлёт под одним именем, чтобы одно имя оз
MUST NOT удерживаться после того, как разбор прошёл мимо неё: тела доходят до MUST NOT удерживаться после того, как разбор прошёл мимо неё: тела доходят до
42 МиБ, и удержание кучи здесь — часть контракта, а не деталь реализации. 42 МиБ, и удержание кучи здесь — часть контракта, а не деталь реализации.
Покрытым сегодня является ровно один ключ — `metrics`. Разбор и перечисление Покрытых ключей сегодня три — `metrics`, `workouts` и `stateOfMind`. Разбор и
MUST ходить по одному объявленному множеству покрытых имён: состояние «секция перечисление MUST ходить по одному объявленному множеству покрытых имён:
разбирается, но числится непокрытой» невыразимо по построению. состояние «секция разбирается, но числится непокрытой» невыразимо по построению.
Непокрытым ключ считается независимо от того, что лежит внутри: содержимое не Непокрытым ключ считается независимо от того, что лежит внутри: содержимое не
интерпретируется, поэтому и о пустоте секции разбор честно ничего не знает. интерпретируется, поэтому и о пустоте секции разбор честно ничего не знает.
Измерено на живом архиве — пустых секций HAE не присылает ни разу (99 доставок). Измерено на живом архиве — пустых секций HAE не присылает ни разу (118 доставок).
Список SHALL быть каноничен: имена отсортированы, повторов нет. Порядок ключей в Список SHALL быть каноничен: имена отсортированы, повторов нет. Порядок ключей в
JSON от HAE нестабилен, а значение уезжает в базу и сравнивается между JSON от HAE нестабилен, а значение уезжает в базу и сравнивается между
доставками. доставками.
Отсутствие непокрытых ключей и отсутствие секции `metrics` — разные события, и Отсутствие непокрытых ключей и отсутствие секции `metrics` — разные события, и
оба нормальны: половина потока состоит из доставок без метрик вовсе (48 из 99). оба нормальны: половина потока состоит из доставок без метрик вовсе (53 из 118).
#### Scenario: Незнакомая секция попадает в список непокрытых #### Scenario: Незнакомая секция попадает в список непокрытых
- **WHEN** тело содержит `data.workouts` наряду с `data.metrics` - **WHEN** тело содержит `data.ecg` наряду с `data.metrics`
- **THEN** разбор возвращает `workouts` в списке непокрытых ключей - **THEN** разбор возвращает `ecg` в списке непокрытых ключей
- **AND** точки секции `metrics` разбираются как обычно - **AND** точки секции `metrics` разбираются как обычно
#### Scenario: Доставка без метрик разбирается и не теряется #### Scenario: Доставка из одних тренировок непокрытых ключей не даёт
- **WHEN** тело содержит только `data.workouts`
- **THEN** разбор завершается без ошибки, точек нет, тренировки разобраны
- **AND** список непокрытых ключей пуст
#### Scenario: Доставка из одного состояния разума непокрытых ключей не даёт
- **WHEN** тело содержит только `data.stateOfMind` - **WHEN** тело содержит только `data.stateOfMind`
- **THEN** разбор завершается без ошибки, точек нет - **THEN** разбор завершается без ошибки, записи разобраны
- **AND** `stateOfMind` возвращается в списке непокрытых ключей - **AND** список непокрытых ключей пуст
#### Scenario: Доставка из одних метрик непокрытых ключей не даёт #### Scenario: Доставка из одних метрик непокрытых ключей не даёт
@@ -345,15 +385,26 @@ JSON от HAE нестабилен, а значение уезжает в баз
### Requirement: Отказ разбора остаётся всё или ничего ### Requirement: Отказ разбора остаётся всё или ничего
Разбор SHALL оставаться операцией «всё или ничего»: ошибка, встреченная Разбор SHALL оставаться операцией «всё или ничего»: ошибка, встреченная
**после** того, как секция `metrics` уже разобрана (обрезанное тело, мусор в **после** того, как покрытая секция уже разобрана (обрезанное тело, мусор в
следующем члене), MUST NOT оставлять точки в результате — доставка считается следующем члене), MUST NOT оставлять в результате ни точек, ни сущностей —
неразобранной целиком. доставка считается неразобранной целиком.
Иначе часть точек оказалась бы в витрине под статусом, по которому доставку Иначе часть данных оказалась бы в витрине под статусом, по которому доставку
никто не подберёт, и свёртка перестала бы быть детерминированной по журналу. никто не подберёт, и свёртка перестала бы быть детерминированной по журналу.
Повтор ключа `metrics` в одном объекте `data` SHALL давать объединение секций, а Правило SHALL распространяться и на невыводимый слой: доставка, у которой есть
не победу последней: молча терять точки нельзя. метрики, но слой их не определяется, не сохраняет и своих сущностей, хотя слоя
у сущности нет. Соблазн «сущности от слоя не зависят, запишем их» ломает то же
«всё или ничего» — доставка получила бы `failed` при частично записанной
витрине, и повторная свёртка перестала бы быть no-op. Цена названа вслух: если
такая доставка когда-нибудь принесёт `stateOfMind`, его записи доедут не сразу,
а пересборкой; тело при этом остаётся в архиве, и `failed` ретеншену трогать
нельзя.
Повтор ключа покрытой секции в одном объекте `data` SHALL давать объединение
секций, а не победу последней: молча терять данные нельзя. То же SHALL
относиться к повтору самого члена `data` в теле — результаты **накапливаются**,
включая список непокрытых ключей.
#### Scenario: Тело оборвано после секции метрик #### Scenario: Тело оборвано после секции метрик
@@ -361,8 +412,173 @@ JSON от HAE нестабилен, а значение уезжает в баз
оборван оборван
- **THEN** разбор завершается ошибкой и точек не отдаёт - **THEN** разбор завершается ошибкой и точек не отдаёт
#### Scenario: Тело оборвано после секции тренировок
- **WHEN** тело содержит целую секцию `workouts`, а следующий член `data`
оборван
- **THEN** разбор завершается ошибкой и сущностей не отдаёт
#### Scenario: Невыводимый слой не сохраняет и сущностей
- **WHEN** доставка несёт метрики, слой которых не определяется, и вместе с
ними секцию `stateOfMind`
- **THEN** разбор завершается ошибкой, ни точек, ни записей не отдаёт
- **AND** список непокрытых ключей переживает отказ
#### Scenario: Секция метрик встречается дважды #### Scenario: Секция метрик встречается дважды
- **WHEN** объект `data` содержит два ключа `metrics` - **WHEN** объект `data` содержит два ключа `metrics`
- **THEN** точки обеих секций попадают в результат - **THEN** точки обеих секций попадают в результат
#### Scenario: Секция тренировок встречается дважды
- **WHEN** объект `data` содержит два ключа `workouts`
- **THEN** сущности обеих секций попадают в результат
#### Scenario: Член `data` встречается дважды
- **WHEN** тело содержит два члена `data`, из которых первый несёт непокрытую
секцию, а второй — покрытую
- **THEN** данные покрытой секции попадают в результат
- **AND** имя непокрытой секции остаётся в списке непокрытых ключей
### Requirement: Разбор секций с собственными идентификаторами
Система SHALL разбирать секции тела, элементы которых несут собственный `id`, в
**сущности**, а не в точки: у сущности нет ни слоя, ни координатного ключа
`метрика + слой + начало + конец` — её адресует сам `id`.
Покрываются две такие секции: `data.workouts` и `data.stateOfMind`. Секции
`ecg`, `symptoms`, `cycleTracking`, `medications` и `heartRateNotifications`
покрытыми MUST NOT становиться: живой поток не приносил их ни разу (118
доставок), их форма никем не наблюдалась, а полнота покрытия HealthKit ради
полноты целью проекта не является. Они остаются в списке непокрытых, и доставка
с ними остаётся `partial`.
Из тренировки разбор SHALL брать только то, по чему потом идёт выборка:
идентификатор, имя, начало, конец, офсет исходной зоны и длительность. Всё
остальное — включая маршрут, внутренние ряды (`heartRateData`,
`activeEnergy`, `heartRateRecovery`) и сводки — MUST храниться содержимым
сущности **дословно**, теми же байтами, какими пришло. Раскладывать структуру
тренировки по колонкам значило бы решить за Apple, что в ней главное: сводки
дублируют ряды (`distance` — это сумма `walkingAndRunningDistance`), а набор
полей зависит от типа тренировки (у уличной есть `route`, `avgSpeed`,
`flightsClimbed`, у домашней — `temperature`, `humidity`, `intensity`).
Длительность SHALL браться из тела, а не вычисляться из начала и конца: HAE
шлёт `91.746` секунды при интервале в 91 секунду, и вычисленное значение молча
разошлось бы с присланным. Отсутствие или нечисловое значение длительности
сущность MUST NOT отбрасывать; такая длительность SHALL быть выражена
отсутствием значения, а не нулём — ноль является законной длительностью, и
потребитель не отличил бы «источник не прислал» от «измерено ноль».
Началом сущности SHALL быть `start`, при его отсутствии — `date`. Конец берётся
из `end`; при отсутствии или неразбираемости конца он SHALL равняться началу, а
истина остаётся в содержимом. Вырождение интервала здесь безопасно, в отличие от
точки: ключ сущности — `id`, схлопывать координаты нечем. Офсет исходной зоны
SHALL браться из начала: колонка одна, а тренировка через смену зоны дала бы
два разных.
Из записи разбор SHALL брать идентификатор, род секции, метку времени и офсет;
всё остальное хранится дословно. Род записи SHALL быть верхнеуровневым ключом
секции HAE **дословно** (`stateOfMind`, не `state_of_mind`): инвариант «форма
Apple не транслируется» относится и к именам секций.
Длина идентификатора SHALL быть ограничена, и сущность с более длинным `id`
SHALL пропускаться тем же счётчиком, что и сущность без `id`. Идентификатор
приходит из тела, которым отправитель управляет целиком, а уезжает и в ключ
таблицы, и в записи лога; правило то же, что уже действует для имён непокрытых
секций.
Ряд пульса **внутри** тренировки MUST NOT попадать в метрику `heart_rate`:
это разные сущности хранилища. Пульс приезжает дважды — в общем потоке метрик и
внутри тренировки, — и смешение задвоило бы ряд.
Сущность без `id` либо без разбираемой метки времени SHALL пропускаться со
счётчиком, не роняя разбор остального: тело остаётся в архиве, и доставку
подберёт пересборка, когда разбор научится её понимать.
Отсутствие покрытой секции в теле ошибкой быть MUST NOT: доставки из одних
метрик — большинство потока.
#### Scenario: Тренировка разбирается вместе с маршрутом
- **WHEN** тело содержит `data.workouts` с тренировкой, несущей `route`
- **THEN** разбор отдаёт сущность с идентификатором, именем, началом, концом,
офсетом и длительностью
- **AND** её содержимое несёт маршрут и внутренние ряды исходными байтами
#### Scenario: Ряд пульса тренировки не становится метрикой
- **WHEN** тренировка содержит `heartRateData`
- **THEN** точки этого ряда не попадают в точки метрик
- **AND** остаются внутри содержимого сущности
#### Scenario: Запись состояния разума разбирается
- **WHEN** тело содержит `data.stateOfMind` с элементом, несущим `id` и `start`
- **THEN** разбор отдаёт запись с родом `stateOfMind`, идентификатором, меткой
времени и содержимым исходными байтами
#### Scenario: Сущность без идентификатора пропускается
- **WHEN** элемент покрытой секции не несёт `id` либо `id` пуст
- **THEN** сущность в результат разбора не попадает
- **AND** факт учитывается счётчиком, а разбор остальных сущностей продолжается
#### Scenario: Элемент секции не является объектом
- **WHEN** элемент покрытой секции не разбирается как объект JSON
- **THEN** сущность в результат разбора не попадает
- **AND** факт учитывается **отдельным** счётчиком, а соседние сущности
разбираются как обычно
Отдельным, а не общим с «нет `id`»: доставка, где не разобрался сам элемент, —
это сменившаяся форма секции, а доставка без `id` — сменившаяся форма
идентификатора. Ронять из-за такого элемента всю доставку нельзя тем более:
`failed` фоновая свёртка не подбирает никогда, и вместе с одной кривой
тренировкой в него уехали бы записи `stateOfMind` той же доставки.
#### Scenario: Сущность без разбираемой метки времени пропускается
- **WHEN** элемент покрытой секции несёт `id`, но его метка времени не
разбирается ни одним из поддерживаемых форматов
- **THEN** сущность в результат разбора не попадает
- **AND** факт учитывается счётчиком
#### Scenario: Сущность со слишком длинным идентификатором пропускается
- **WHEN** элемент покрытой секции несёт `id` длиннее предела
- **THEN** сущность в результат разбора не попадает
- **AND** факт учитывается тем же счётчиком, что и отсутствие `id`
#### Scenario: Длительность берётся из тела, а не из интервала
- **WHEN** тренировка несёт `duration` равный `91.746` при интервале
`start`/`end` в 91 секунду
- **THEN** длительность сущности равна `91.746`
#### Scenario: Тренировка без длительности сохраняется без неё
- **WHEN** тренировка не несёт `duration` либо оно не является числом
- **THEN** сущность сохраняется, а её длительность остаётся незаполненной
- **AND** нулём она MUST NOT становиться
#### Scenario: Нечитаемый конец тренировки не отбрасывает её
- **WHEN** тренировка несёт `end`, который не разбирается
- **THEN** конец сущности равен её началу
- **AND** исходное значение остаётся в содержимом дословно
#### Scenario: Незнакомое поле тренировки переживает разбор
- **WHEN** тренировка несёт поле, которого разбор не знает
- **THEN** оно сохраняется в содержимом сущности дословно
- **AND** разбор не завершается ошибкой
#### Scenario: Непокрытая секция с собственными id остаётся непокрытой
- **WHEN** тело содержит `data.ecg`
- **THEN** `ecg` попадает в список непокрытых ключей
- **AND** сущностей из неё разбор не отдаёт
+20 -2
View File
@@ -309,6 +309,12 @@
отпечатка** — рабочей витрины и пересобранной, — с прямым ответом, совпали они отпечатка** — рабочей витрины и пересобранной, — с прямым ответом, совпали они
или нет. или нет.
Счётчики «до и после» SHALL покрывать **каждую единицу хранения витрины**:
часовые объекты, тренировки и записи. Отпечаток отвечает «да/нет» за витрину
целиком, поэтому единица, которой нет в счётчиках, делает расхождение
безадресным: человек увидит «не совпало» при неизменившемся числе объектов и не
отличит появление двадцати семи тренировок от пропажи двух.
Отказы SHALL считаться **по классам**: слой не выводится, содержимое не Отказы SHALL считаться **по классам**: слой не выводится, содержимое не
разбирается, работа отложена по обстоятельствам, всё прочее. Невыведенный слой разбирается, работа отложена по обстоятельствам, всё прочее. Невыведенный слой
есть в каждом журнале и штатен; общий счётчик отправлял бы человека искать есть в каждом журнале и штатен; общий счётчик отправлял бы человека искать
@@ -342,8 +348,14 @@
пересобранной витрины и число доставок после прогона не измеряются вовсе — пересобранной витрины и число доставок после прогона не измеряются вовсе —
печатать их сравнение значило бы выдать неизмеренное за измеренное, причём в печатать их сравнение значило бы выдать неизмеренное за измеренное, причём в
единственном оракуле задачи. Ожидаемые классы расхождения (новые доставки за время прогона, единственном оракуле задачи. Ожидаемые классы расхождения (новые доставки за время прогона,
непереносимый признак запечатанного часа, исправленный разбор) SHALL называться непереносимый признак запечатанного часа, исправленный разбор, **покрытая
отдельно от самого факта расхождения. разбором новая секция**) SHALL называться отдельно от самого факта расхождения.
Класс «покрыта новая секция» назван потому, что первый прогон после такого
изменения расходится **гарантированно** и штатно: витрина обзаводится единицами
хранения, которых в рабочей базе нет по построению. Не назвав его, отчёт
приучает человека игнорировать расхождение отпечатков — то есть обесценивает
оракул ровно тогда, когда по нему принимается необратимое решение.
**Исход команды.** Расхождение отпечатков отказом быть MUST NOT: после **Исход команды.** Расхождение отпечатков отказом быть MUST NOT: после
исправления разбора оно ожидаемо и есть сам смысл пересборки. Отказ отдельной исправления разбора оно ожидаемо и есть сам смысл пересборки. Отказ отдельной
@@ -373,6 +385,12 @@ SHALL идти в поток ошибок, а не смешиваться с о
- **AND** прямо называет, совпали они или нет - **AND** прямо называет, совпали они или нет
- **AND** называет, изменилось ли число доставок в рабочей базе за время прогона - **AND** называет, изменилось ли число доставок в рабочей базе за время прогона
#### Scenario: Счётчики покрывают все единицы хранения
- **WHEN** пересборка завершилась
- **THEN** отчёт печатает «до и после» отдельно для часовых объектов,
тренировок и записей
#### Scenario: Расхождение отпечатков не является отказом #### Scenario: Расхождение отпечатков не является отказом
- **WHEN** отпечаток пересобранной витрины отличается от рабочей, и при этом - **WHEN** отпечаток пересобранной витрины отличается от рабочей, и при этом
+285 -4
View File
@@ -301,26 +301,39 @@ HTML-экранирования: `&`, `<` и `>` внутри точки обя
### Requirement: Значения точек не попадают в логи ### Requirement: Значения точек не попадают в логи
Данные о здоровье чувствительнее токенов. Система MUST NOT писать значения Данные о здоровье чувствительнее токенов. Система MUST NOT писать значения
точек и тела доставок в записи лога уровня выше `DEBUG`. точек, содержимое сущностей и тела доставок в записи лога уровня выше `DEBUG`.
Содержимое сущности здесь не менее чувствительно, чем значение точки, а местами
более: маршрут тренировки — это геотрек до дома, а `labels` и `associations`
записи состояния разума — эмоциональные метки. Разрешены **координаты**:
идентификатор и род сущности, метка времени, идентификатор доставки — они
описывают, что случилось, а не что измерено.
Непокрытые секции называются в логе **именами ключей**: имя секции — это форма Непокрытые секции называются в логе **именами ключей**: имя секции — это форма
пакета, а не измерение. Содержимое секции в лог не попадает ни при каком уровне пакета, а не измерение. Содержимое секции в лог не попадает ни при каком уровне
выше `DEBUG`. Имена идут структурным атрибутом, а не склейкой в текст сообщения: выше `DEBUG`. Имена идут структурным атрибутом, а не склейкой в текст сообщения:
кодировщик экранирует управляющие символы, и имя из чужого тела не разрывает кодировщик экранирует управляющие символы, и имя из чужого тела не разрывает
построчный разбор логов. построчный разбор логов. То же относится к идентификатору сущности: он приходит
из чужого тела и ограничен по длине при разборе.
Частичный разбор уровня записи не повышает: `partial` — установившееся состояние Частичный разбор уровня записи не повышает: `partial` — установившееся состояние
половины потока (48 доставок из 99), и постоянный `WARN` обесценил бы уровень. половины потока (53 доставки из 118), и постоянный `WARN` обесценил бы уровень.
Повышает уровень другое — срабатывание границ списка: тело с сотнями секций или Повышает уровень другое — срабатывание границ списка: тело с сотнями секций или
с именем длиннее предела на HAE не похоже вовсе. с именем длиннее предела на HAE не похоже вовсе.
#### Scenario: Разбор доставки логируется без значений #### Scenario: Разбор доставки логируется без значений
- **WHEN** доставка разобрана - **WHEN** доставка разобрана
- **THEN** запись лога содержит счётчики (метрик, точек, объектов) и - **THEN** запись лога содержит счётчики (метрик, точек, объектов, сущностей) и
идентификатор доставки идентификатор доставки
- **AND** не содержит ни значений точек, ни имён устройств - **AND** не содержит ни значений точек, ни имён устройств
#### Scenario: Удержанная обеднённая версия логируется координатами
- **WHEN** приехавшая версия сущности отклонена как теряющая содержание
- **THEN** запись `WARN` содержит идентификатор и род сущности
- **AND** не содержит ни точек маршрута, ни того, какие поля потерялись
#### Scenario: Непокрытые секции названы именами ключей #### Scenario: Непокрытые секции названы именами ключей
- **WHEN** доставка содержит непокрытую секцию - **WHEN** доставка содержит непокрытую секцию
@@ -427,3 +440,271 @@ Apple его нет (находка 46). Поэтому список MUST сох
- **THEN** `parse_status` становится `parsed` - **THEN** `parse_status` становится `parsed`
- **AND** сохранённый список непокрытых ключей пуст - **AND** сохранённый список непокрытых ключей пуст
### Requirement: Хранение сущностей с собственным идентификатором
Система SHALL хранить тренировки и записи секций с собственным `id` **не**
часовыми объектами, а по одной строке на сущность: у них есть естественный
ключ, они редки (за двое суток потока — две тренировки и две записи состояния
разума), и группировать их по часам незачем.
Единиц хранения две:
```
тренировка ключ id
заголовок колонками: имя, начало, конец, офсет зоны, длительность
запись ключ род секции + id
заголовок колонками: род, метка времени, офсет зоны
```
Сущность SHALL нести **провенанс** — идентификатор доставки, чья версия лежит
сейчас, и метку приёма этой доставки. Он нужен не отчётности: по нему
разрешается тай-брейк между версиями равной полноты (см. «Замена версии
сущности…»), и без него `WARN` об удержанной обеднённой версии не связать с
телом в архиве.
Длительность тренировки SHALL допускать отсутствие значения, отличимое от нуля:
ноль — законная длительность, и потребитель, сложивший столбец, иначе не отличил
бы «источник не прислал» от «измерено ноль».
Ключ записи SHALL быть парой `род + id`, а не одним `id`. Собственный `id`
наблюдался живьём только у `stateOfMind`, где он UUID HealthKit; форма
идентификатора остальных пяти секций не наблюдалась никем, и короткий
несквозной `id` в двух разных секциях затёр бы одну запись другой молча. Пара
стоит ноль: запросы к записям всегда идут с родом.
Содержимое сущности SHALL храниться **дословно** — теми же байтами, какими
пришло, включая маршрут, внутренние ряды и сводки. Заголовок колонками
существует ради выборки по времени и не является разбором содержимого: любая
следующая колонка была бы решением за Apple о том, что в тренировке главное.
Ряд пульса внутри тренировки MUST лежать в её содержимом, а не в объектах
метрики `heart_rate`: это разные таблицы, и смешение задвоило бы ряд.
Сущности доставки SHALL записываться **той же транзакцией**, что и её точки.
Доставка — единица свёртки; частичное состояние ломает инвариант «состояние
пересобираемо», а наблюдение «секции не смешиваются в одной доставке» собрано
за двое суток и основанием для второй транзакции не является.
Система SHALL хранить рядом с сущностью хеш её канонического содержимого и
пропускать запись, если хеш не изменился. Тренировка переприсылается каждой
доставкой автоматизации, пока не доедет маршрут: на живом архиве 44 доставленные
копии дают три различных содержимых.
Сравнение SHALL начинаться с хеша, читаемого **без** содержимого сохранённой
сущности: маршрут доходит до мегабайта, разжимать и канонизировать его на каждой
из 44 копий не за чем. Хеш приехавшей сущности SHALL считаться один раз на
доставку, а не на каждой попытке повтора транзакции при занятости базы:
канонизация материализует значение целиком, и повтор умножал бы пик кучи.
#### Scenario: Тренировка хранится одной строкой с маршрутом
- **WHEN** приезжает тренировка с маршрутом
- **THEN** она хранится одной строкой, адресуемой своим `id`
- **AND** маршрут и внутренние ряды лежат в её содержимом дословно
#### Scenario: Ряд пульса тренировки не попадает в метрику
- **WHEN** тренировка несёт `heartRateData`
- **THEN** объектов метрики `heart_rate` эта доставка не создаёт
#### Scenario: Записи разных родов с одинаковым id не сталкиваются
- **WHEN** две записи разных родов приезжают с одним и тем же `id`
- **THEN** в хранилище лежат обе
#### Scenario: Повторная присылка той же тренировки не пишет в базу
- **WHEN** приезжает тренировка, содержимое которой совпадает с сохранённым
- **THEN** хеш совпадает и запись не выполняется
#### Scenario: Отказ посреди доставки не оставляет части сущностей
- **WHEN** свёртка доставки прерывается на середине
- **THEN** не записывается ни одна сущность этой доставки
### Requirement: Замена версии сущности не теряет содержания
Сущность с собственным `id` SHALL замещаться **целиком**, а не сливаться по
полям: она приезжает повторно, пока источник её досчитывает. Замер на живом
архиве: одна тренировка приехала 26 раз в трёх различных содержимых — сперва
добавились `stepCadence` и `stepCount` вместе с изменившимся рядом
`activeEnergy`, затем при том же наборе полей досчитались `totalEnergy` и
`basalEnergy`.
Замещение MUST быть условным: приехавшая версия побеждает, **если не теряет
содержания** сохранённой. Порядок разбора:
```
1. хеш канонического содержимого совпал → записи нет
2. содержание приехавшей покрывает сохранённую
и сверх того → приехавшая замещает целиком
3. приехавшая теряет содержание сохранённой → остаётся сохранённая,
счётчик + WARN
4. содержание сравнимо, наборы равны → версия из более поздней
доставки журнала
5. наборы несравнимы → остаётся сохранённая,
счётчик + WARN
```
**Содержание сравнивается множеством ключей с непустым значением — и только им.**
Сравнение полноты, принятое для точек, здесь неприменимо: оно гасит отношение
включения, когда значения общих содержательных ключей разошлись, а у сущности
они расходятся **всегда** — источник её досчитывает. Проверено: сохранённая
тренировка с маршрутом против приехавшей без маршрута даёт «надмножество» при
неизменных значениях и «равенство» при изменившихся, то есть на живых данных
защита не сработала бы вовсе, а тест на фикстуре с неизменёнными значениями
остался бы зелёным. Условия «значения общих ключей совпали» здесь быть MUST NOT.
Дополнительно к множеству ключей SHALL сравниваться **длина верхнеуровневых
массивов**: усечённый маршрут (три точки вместо 593) ключа не теряет, а теряет
95% содержимого тренировки. Досчёт ряды удлиняет, поэтому укорачивание —
законный признак «приехало меньше». Предел правила называется вслух: сокращение
**внутри** элемента ряда (точка маршрута без `altitude`) не ловится ничем, кроме
сверки с телом в архиве.
Единственная причина повторной присылки — доезжающий маршрут, то есть рост:
обратного за 44 доставленные копии не случилось ни разу. Но восстановление
требует пересборки всего журнала, поэтому событие делается наблюдаемым, а не
необратимым.
**Тай-брейк при равных наборах — позиция доставки в журнале `(received_at, id)`,
а не порядок свёртки.** «Побеждает приехавшая» было бы функцией порядка
свёртки, а он порядку журнала не равен: воркер сворачивает в порядке журнала
только среди видимых ему доставок и абсолютного порядка при конкурентных
приёмах не обещает. Доставка с более ранней меткой, свёрнутая позже, вернула бы
витрину к недосчитанной версии, и пересборка разошлась бы с живым приёмом молча,
в содержимом тренировки. Позиция журнала снимает это: исход зависит от журнала,
а не от того, кто раньше добрался до базы.
Отличие от точки здесь содержательное: у точки на одних координатах законно
встречаются два разных измерения, и предпочитать позднее нет оснований — там
исход решает порядок канонических форм. У сущности `id` — идентичность одного
объекта HealthKit, и вторая версия есть тот же объект, пересчитанный источником;
тай-брейк по канонической форме заморозил бы тренировку на произвольной из
версий навсегда, вместе с недосчитанной энергией.
Две версии одного ключа **внутри одной доставки** позициями не различаются и
SHALL разрешаться минимумом канонической формы — включая случай несравнимых
наборов. Внутри доставки «сохранённой» версии не существует, есть только
порядок элементов в JSON-массиве, а он нестабилен: правило «остаётся первая
встреченная» сделало бы исход функцией порядка на проводе. Сворачиваться между
собой такие версии SHALL до сравнения с сохранённой, а факт «в одном теле
приехали две версии одного ключа с разным содержанием» SHALL считаться
**симметрично**: счётчик, зависящий от порядка элементов, наблюдал бы событие
через раз.
Поля версий MUST NOT объединяться: несравнимые наборы (приехавшая принесла
новые ключи и потеряла старые) разрешаются в пользу сохранённой и считаются
тем же счётчиком. Объединение отвергнуто там же и по той же причине, что для
точек: на живом потоке событие не наступало, и вместо реализации заведено
наблюдение.
Исход SHALL быть функцией журнала в его порядке. Остаточный предел называется
вслух: слияние попарное — сохранённая против приехавшей, — поэтому при
несравнимых наборах (пункт 5) исход зависит от порядка проигрывания. Тот же
предел есть у часового объекта, где хранится победитель прошлых слияний, а не
все кандидаты истории; пункты 2–4 от порядка свёртки не зависят, а пункт 5
сопровождается счётчиком и `WARN`.
#### Scenario: Доехавший маршрут замещает тренировку без маршрута
- **WHEN** та же тренировка приезжает повторно, добавив `route`
- **THEN** в хранилище лежит версия с маршрутом
#### Scenario: Досчитанные значения при том же наборе полей побеждают
- **WHEN** та же тренировка приезжает повторно с тем же набором полей и
изменившимися значениями, доставкой с более поздней позицией журнала
- **THEN** в хранилище лежит приехавшая версия
#### Scenario: Версия из более ранней доставки не откатывает витрину
- **WHEN** две доставки несут одну тренировку с равными наборами полей, и
свёрнута сперва более поздняя по журналу, затем более ранняя
- **THEN** в хранилище лежит версия из более поздней доставки
- **AND** тот же исход даёт свёртка в обратном порядке
#### Scenario: Обеднённая версия сохранённую не затирает
- **WHEN** та же тренировка приезжает повторно **без** `route`, который был у
сохранённой, **и** с изменившимися значениями общих полей
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается счётчиком и записью `WARN` с идентификатором
тренировки
#### Scenario: Усечённый маршрут сохранённый не затирает
- **WHEN** та же тренировка приезжает повторно с тем же набором полей, но
`route` короче сохранённого
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается тем же счётчиком
#### Scenario: Две версии одной сущности в одном теле
- **WHEN** тело содержит два элемента секции с одним `id`
- **THEN** исход не зависит от их порядка в массиве
- **AND** счётчик различающихся версий тоже не зависит от их порядка
#### Scenario: Составной ключ не даёт коллизии отпечатка
- **WHEN** две витрины различаются только тем, где проходит граница между родом
и идентификатором записи
- **THEN** отпечатки не совпадают
#### Scenario: Несравнимые наборы полей не объединяются
- **WHEN** приехавшая версия несёт содержательный ключ, которого нет у
сохранённой, и теряет содержательный ключ, который у сохранённой есть
- **THEN** в хранилище остаётся сохранённая версия
- **AND** факт учитывается тем же счётчиком
#### Scenario: Повторная свёртка того же журнала состояния не меняет
- **WHEN** те же доставки сворачиваются повторно в том же порядке
- **THEN** содержимое сущностей не меняется
### Requirement: Отпечаток витрины покрывает все её сущности
Отпечаток витрины SHALL включать тренировки и записи наравне с часовыми
объектами: он единственный оракул сходимости пересборки, и отпечаток одних
объектов давал бы «состояние сошлось» при разъехавшихся тренировках — то есть
ломался бы молча ровно тем изменением, которое добавляет данные.
В отпечаток идут координаты сущности и хеш её содержимого. Значений он
раскрывать MUST NOT — как и для точек.
Порядок обхода SHALL быть детерминированным и заданным запросом, а не порядком
строк в файле базы. Строки разных разделов SHALL различаться константным
признаком раздела: без него строка одного раздела может совпасть со строкой
другого, и два разных состояния дали бы один отпечаток. По той же причине
составной ключ SHALL идти в отпечаток **отдельными полями с собственными
длинами**, а не склейкой: склейка выполняется до взятия длины, и пара
(`a`, `b/c`) даёт ту же строку, что (`a/b`, `c`).
Границу оракула стоит назвать вслух: в отпечаток идут координаты и хеш
содержимого, а **колонки заголовка** сущности (имя, конец интервала, офсет,
длительность) — нет. Они производны от содержимого, поэтому их расхождение
означает изменившийся код извлечения заголовка, а не разъехавшееся состояние; но
«отпечатки совпали» не является утверждением о них.
Все разделы SHALL читаться **одним снимком** базы. Отпечаток рабочей витрины
снимается под живым приёмом, и запросы вне общей транзакции чтения дали бы смесь
«объекты до» и «тренировки после» — то есть ложное расхождение у единственного
оракула сходимости.
#### Scenario: Расхождение тренировок видно в отпечатке
- **WHEN** две витрины совпадают по часовым объектам, но содержимое одной
тренировки различается
- **THEN** отпечатки не совпадают
#### Scenario: Отпечаток одинаков при одинаковом содержимом
- **WHEN** та же витрина собрана повторно из того же журнала
- **THEN** отпечаток совпадает
#### Scenario: Запись во время снятия отпечатка не смешивает разделы
- **WHEN** отпечаток снимается, а параллельно коммитится свёртка
- **THEN** отпечаток отражает одно состояние базы, а не смесь снимков