From f8200f7f801dbc6acd3214710e4b74188ece93d4 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sun, 2 Aug 2026 13:05:16 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=80=D0=B0=D0=B7=D0=B1=D0=BE=D1=80=20?= =?UTF-8?q?=D0=B8=20=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D1=82=D1=80=D0=B5=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=BE=D0=BA=20?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D1=80=D0=B0=D0=B7=D1=83=D0=BC=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`; миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки с этими ключами - сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА — «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина расходилась бы с пересборкой молча - отпечаток витрины покрывает тренировки и записи и снимается одним снимком базы; отчёт `reindex` считает «было и стало» по каждой единице хранения --- cmd/healthlog/reindex.go | 17 +- cmd/healthlog/reindex_report.go | 9 +- cmd/healthlog/reindex_test.go | 28 +- docs/architecture.md | 131 +++- docs/backlog/README.md | 2 +- .../identichnost-trenirovok-pri-importe.md | 37 ++ docs/backlog/proverka-novyh-sekcij.md | 7 + docs/backlog/read-api-tochki.md | 11 +- docs/backlog/retenshen-syrogo-arhiva.md | 40 +- docs/backlog/trenirovki-i-zapisi.md | 23 - docs/conventions.md | 18 +- docs/database.md | 54 ++ docs/local-research.md | 67 ++ internal/canon/canon.go | 66 ++ internal/fold/fold.go | 113 +++- internal/fold/fold_test.go | 4 +- internal/fold/log_test.go | 135 +++- internal/hae/entity.go | 144 +++++ internal/hae/entity_test.go | 318 ++++++++++ internal/hae/hae.go | 208 +++++-- internal/hae/hae_test.go | 23 +- internal/hae/mem_test.go | 15 +- internal/hae/testdata/README.md | 21 + internal/hae/testdata/handmade_entities.json | 110 ++++ internal/hae/testdata/state_of_mind.json | 36 ++ internal/hae/testdata/uncovered_sections.json | 105 +++- internal/hae/testdata/workout_indoor.json | 176 ++++++ internal/hae/testdata/workout_route.json | 588 ++++++++++++++++++ internal/replay/archive_test.go | 23 +- internal/replay/replay.go | 19 +- internal/store/bucket.go | 197 +++++- internal/store/bucket_test.go | 125 ++-- internal/store/entity.go | 586 +++++++++++++++++ internal/store/entity_test.go | 460 ++++++++++++++ internal/store/migration_test.go | 74 +++ .../store/migrations/00007_workout_record.sql | 123 ++++ internal/store/regress_test.go | 32 +- .../.openspec.yaml | 2 + .../2026-08-02-trenirovki-i-zapisi/design.md | 415 ++++++++++++ .../proposal.md | 80 +++ .../specs/parsing/spec.md | 324 ++++++++++ .../specs/reindex/spec.md | 133 ++++ .../specs/storage/spec.md | 320 ++++++++++ .../2026-08-02-trenirovki-i-zapisi/tasks.md | 126 ++++ openspec/specs/parsing/spec.md | 262 +++++++- openspec/specs/reindex/spec.md | 22 +- openspec/specs/storage/spec.md | 289 ++++++++- 47 files changed, 5817 insertions(+), 301 deletions(-) create mode 100644 docs/backlog/identichnost-trenirovok-pri-importe.md delete mode 100644 docs/backlog/trenirovki-i-zapisi.md create mode 100644 internal/hae/entity.go create mode 100644 internal/hae/entity_test.go create mode 100644 internal/hae/testdata/handmade_entities.json create mode 100644 internal/hae/testdata/state_of_mind.json create mode 100644 internal/hae/testdata/workout_indoor.json create mode 100644 internal/hae/testdata/workout_route.json create mode 100644 internal/store/entity.go create mode 100644 internal/store/entity_test.go create mode 100644 internal/store/migrations/00007_workout_record.sql create mode 100644 openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/design.md create mode 100644 openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/proposal.md create mode 100644 openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/specs/parsing/spec.md create mode 100644 openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/specs/reindex/spec.md create mode 100644 openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/specs/storage/spec.md create mode 100644 openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/tasks.md diff --git a/cmd/healthlog/reindex.go b/cmd/healthlog/reindex.go index edb8909..5bb9f2a 100644 --- a/cmd/healthlog/reindex.go +++ b/cmd/healthlog/reindex.go @@ -174,9 +174,14 @@ type report struct { dbPath string sourcePrint string sourceBuckets int64 - sourceBefore int64 - sourceAfter int64 - sourceMissing bool + // sourceWorkouts и sourceRecords — то же «было» для остальных единиц + // хранения витрины. Отпечаток отвечает «да/нет» за витрину целиком, поэтому + // единица, которой нет в счётчиках, делает расхождение безадресным. + sourceWorkouts int64 + sourceRecords int64 + sourceBefore int64 + sourceAfter int64 + sourceMissing bool } // rebuild собирает витрину в промежуточный файл и переименовывает его в файл @@ -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 { 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) diff --git a/cmd/healthlog/reindex_report.go b/cmd/healthlog/reindex_report.go index f309859..1a25c37 100644 --- a/cmd/healthlog/reindex_report.go +++ b/cmd/healthlog/reindex_report.go @@ -58,6 +58,8 @@ func writeReport(w io.Writer, r report) { if r.sourceMissing { p(" объектов: %d", r.replay.Buckets) + p(" тренировок: %d", r.replay.Workouts) + p(" записей: %d", r.replay.Records) p("") p("рабочей базы %s нет — сверять не с чем, а заголовки доставок", r.dbPath) p("не восстанавливаются: в архиве их нет.") @@ -66,6 +68,8 @@ func writeReport(w io.Writer, r report) { // расхождения. Отпечатки отвечают «да/нет», а решение о подмене // необратимо; именно пара чисел 1737/1742 поймала прошлый дефект. 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(" отпечаток рабочей: %s", r.sourcePrint) p(" отпечаток пересобранной: %s", r.replay.Fingerprint) @@ -76,8 +80,9 @@ func writeReport(w io.Writer, r report) { p(" отпечатки совпали, но сверка НЕПОЛНА: часть журнала не прочитана") default: p(" отпечатки РАЗОШЛИСЬ") - p(" ожидаемые причины: исправленный разбор; признак sealed не") - p(" переносится (правила его выставления ещё нет)") + p(" ожидаемые причины: исправленный разбор; покрытая разбором новая") + p(" секция (её единиц хранения в рабочей базе нет по построению);") + p(" признак sealed не переносится (правила его выставления ещё нет)") if partialJournal { p(" ВНИМАНИЕ: часть журнала не прочитана — расхождение может") p(" объясняться этим, а не разбором") diff --git a/cmd/healthlog/reindex_test.go b/cmd/healthlog/reindex_test.go index 72b7f46..614ca31 100644 --- a/cmd/healthlog/reindex_test.go +++ b/cmd/healthlog/reindex_test.go @@ -88,14 +88,16 @@ func TestОтчётНеРаскрываетДанныхОЗдоровье(t *tes replay: replay.Report{ Bodies: 116, Outcome: replay.Outcome{Folded: 116, Partial: 53}, - Buckets: 2049, Fingerprint: "aaaa", + Buckets: 2049, Workouts: 2, Records: 2, Fingerprint: "aaaa", }, - target: "/data/healthlog.db.rebuild", - dbPath: "/data/healthlog.db", - sourcePrint: "bbbb", - sourceBuckets: 2040, - sourceBefore: 116, - sourceAfter: 116, + target: "/data/healthlog.db.rebuild", + dbPath: "/data/healthlog.db", + sourcePrint: "bbbb", + sourceBuckets: 2040, + sourceWorkouts: 0, + sourceRecords: 0, + sourceBefore: 116, + sourceAfter: 116, }) out := buf.String() @@ -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) { t.Errorf("отчёт не содержит %q", want) } diff --git a/docs/architecture.md b/docs/architecture.md index 6ae2000..2f52b45 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -317,9 +317,11 @@ HRV); у накопительных — только `date`. Поэтому то #### Частичный разбор -Разбор покрывает секцию `metrics`; `workouts`, `stateOfMind`, `symptoms`, `ecg` -и прочие проходят мимо. Это половина потока: 48 доставок из 99 не несут -`metrics` вовсе (находка 50). +Разбор покрывает `metrics`, `workouts` и `stateOfMind`; `symptoms`, `ecg`, +`cycleTracking`, `medications` и `heartRateNotifications` проходят мимо. Живой +поток последних не приносил ни разу (118 доставок: 65 с метриками, 27 с +тренировками, 26 с состоянием разума), так что сегодня непокрытая секция — +редкость, а не половина потока, как было до покрытия сущностей. Такая доставка получает статус `partial`, а имена непокрытых секций — колонку `delivery.uncovered_sections`. Статус отвечает на вопрос «разобрано ли всё», @@ -343,7 +345,15 @@ HRV); у накопительных — только `date`. Поэтому то покрытия на момент свёртки; доставки, свёрнутые до того, как секция стала покрытой, останутся `partial` со старым списком, и ретеншен будет вечно щадить ненужные тела. Задача, которая начинает разбирать секцию, тем же изменением -переводит `partial`-строки с этим ключом в `pending`. +переводит `partial`-строки с этим ключом в `pending`. Так сделала миграция +`00007`, покрывшая `workouts` и `stateOfMind`. + +**Следствие для ретеншена, названное вслух.** Пока `stateOfMind` был непокрыт, +его тела защищал сам статус `partial`. Теперь такая доставка получает `parsed` и +неотличима от доставки из метрик — а метрики восстановимы из экспорта Apple, +состояние разума нет (находка 46). Ретеншена в проекте нет, поэтому сегодня не +ломается ничего; но предусловие, которое задача ретеншена считала снятым, снова +открыто, и признак невосстановимости придётся завести отдельно от «непокрытости». - **413** — тело больше допустимого. Граница стоит на **распакованном** потоке, а не только на сжатом: `MaxBytesReader` поверх `r.Body` ограничивает @@ -551,12 +561,14 @@ bucket(metric, layer, hour_utc, units, payload BLOB, content_hash, points, first_ts, last_ts, first_delivery_id, sealed, created_at, updated_at) PK (metric, layer, hour_utc) WITHOUT ROWID -workout(id PK, name, start_utc, end_utc, tz_offset, duration_sec, - payload JSON, delivery_id, updated_at) +workout(id PK, name, start_utc, end_utc, tz_offset, duration_sec REAL NULL, + payload BLOB, content_hash, delivery_id, delivery_received_at, + created_at, updated_at) + INDEX (start_utc) -record(id PK, kind, ts_utc, tz_offset, payload JSON, - delivery_id, updated_at) - INDEX (kind, ts_utc) +record(kind, id, ts_utc, tz_offset, payload BLOB, content_hash, + delivery_id, delivery_received_at, created_at, updated_at) + PK (kind, id) INDEX (kind, ts_utc) ``` Зачем пачками: @@ -814,10 +826,17 @@ value_code "HKCategoryValueSleepAnalysisAsleepREM" ← выведено по с ### Тренировки и прочие секции -Тренировка адресуется своим `id` из HealthKit и **перезаписывается**: она -может приехать повторно, когда доедет маршрут. `record` держит секции с -собственными идентификаторами (`stateOfMind`, `ecg`, `symptoms`, -`cycleTracking`, `medications`, `heartRateNotifications`) — модель та же. +Тренировка адресуется своим `id` из HealthKit, запись — парой `род + id`. +`record` держит секции с собственными идентификаторами; разбором покрыт пока +только `stateOfMind`, а `ecg`, `symptoms`, `cycleTracking`, `medications` и +`heartRateNotifications` остаются непокрытыми **намеренно**: живой поток не +приносил их ни разу, их формы никто не видел, а полнота покрытия HealthKit ради +полноты целью проекта не является. Модель под них заложена — новая секция +добавляется одной строкой в множество покрытых имён, а не миграцией. + +Ключ записи — **пара**, а не один `id`: собственный `id` наблюдался живьём +только у `stateOfMind`, где он UUID, и короткий несквозной идентификатор в двух +разных секциях затёр бы одну запись другой молча. Пачками они не хранятся: у них есть естественный ключ, они редки, и группировать их по часам незачем. @@ -825,12 +844,96 @@ value_code "HKCategoryValueSleepAnalysisAsleepREM" ← выведено по с **Тренировка не разворачивается.** Заголовок — колонками, всё остальное, включая маршрут и внутренние ряды, — блобом `payload`. Структура тренировки разнородна и избыточна (сводки дублируют ряды, находка 15); раскладывать её в -таблицы значило бы решить за Apple, что в ней главное. +таблицы значило бы решить за Apple, что в ней главное. Колонок ровно столько, +сколько нужно выборке: имя, интервал, офсет зоны, длительность. Длительность +берётся из тела, а не считается как `end - start` (HAE шлёт 91.746 при +интервале в 91 секунду), и её отсутствие выражается пустотой, а не нулём — ноль +законная длительность. **Пульс приедет дважды** — в общем потоке метрики `heart_rate` и внутри объекта тренировки. Это ожидаемо, они лежат в разных таблицах и не смешиваются. +#### Замена версии сущности + +«Перезаписывается» уточнено измерением. Тренировка приезжает повторно каждой +доставкой, пока источник её досчитывает: на живом архиве одна тренировка +приехала 26 раз в трёх различных содержимых — сперва добавились `stepCadence` и +`stepCount` вместе с изменившимся рядом `activeEnergy`, затем при том же наборе +полей досчитались `totalEnergy` и `basalEnergy`. То есть тренировка правится +задним числом ровно так же, как минутное ведро (находка 10), а набор её полей +за весь корпус ни разу не уменьшился. + +Правило: + +``` +1. каноническая форма совпала с сохранённой → записи нет (хеш-детектор) +2. приехавшая несёт всё содержание сохранённой + и сверх того → приехавшая замещает целиком +3. приехавшая теряет содержание сохранённой → остаётся сохранённая, + счётчик + WARN +4. содержание равно → версия из более поздней + доставки ЖУРНАЛА +5. наборы несравнимы → остаётся сохранённая, + счётчик + WARN +``` + +**Содержание сравнивается множеством ключей с непустым значением и длиной +верхнеуровневых массивов — но не значениями.** Правило полноты, принятое для +точек, здесь неприменимо, и это проверено выполненной командой: оно гасит +отношение включения до «равенства», когда значения общих ключей разошлись, — а +у сущности они расходятся всегда. Обеднённая версия получила бы «равенство» и +заместила бы сохранённую вместе с маршрутом, причём тест на фикстуре с +неизменёнными значениями остался бы зелёным. Длина массивов добавлена потому, +что усечённый маршрут (три точки вместо 593) ключа не теряет, а теряет 95% веса +тренировки. Предел правила назван вслух: сокращение **внутри** элемента ряда не +ловится ничем, кроме сверки с телом в архиве. + +**Тай-брейк при равном содержании — позиция доставки в журнале +`(received_at, id)`, а не порядок свёртки.** Напрашивавшееся «побеждает +приехавшая» отвергнуто: приехавшая есть функция порядка свёртки, а он порядку +журнала не равен (см. «Предел порядка назван вслух»). Доставка с более ранней +меткой, свёрнутая позже, вернула бы витрину к недосчитанной версии, и +пересборка разошлась бы с живым приёмом **молча** — в содержимом тренировки, где +это не видно ничем, кроме отпечатка. Поэтому сущность несёт провенанс: +доставку своей версии и её метку приёма. Тай-брейк по канонической форме (как у +точек) отвергнут по другой причине: он заморозил бы тренировку на произвольной +из версий навсегда, вместе с недосчитанной энергией. + +Две версии одного ключа **внутри одной доставки** позициями не различаются и +разрешаются минимумом канонической формы: порядок элементов в JSON-массиве +нестабилен. + +Отвергнут и **голый upsert по `id`** (так делает сервер HealthyApps поверх +MongoDB, и так просилось из слова «перезаписывается»): единственный наблюдённый +сценарий повторной присылки — рост, но маршрут стоит 95% содержимого, а +восстановление требует пересборки всего журнала. Условие пункта 3 стоит одного +сравнения множеств и делает событие наблюдаемым вместо необратимого. + +Остаточный предел назван вслух: слияние попарное, поэтому при несравнимых +наборах (пункт 5) исход зависит от порядка проигрывания. Тот же предел есть у +часового объекта — в нём лежит победитель прошлых слияний, а не все кандидаты +истории. + +#### Отпечаток и отчёт пересборки идут за витриной + +Отпечаток покрывает **все** единицы хранения и снимается одной транзакцией +чтения: отпечаток одних часовых объектов давал бы «состояние сошлось» при +разъехавшихся тренировках, а три запроса вне общей транзакции под живым приёмом +дали бы смесь снимков и ложное «разошлись». Отчёт `reindex` считает «было и +стало» по каждой единице и называет «покрыта новая секция» ожидаемым классом +расхождения — иначе первый прогон после такого изменения расходится +гарантированно, а человек читает это как дефект. + +#### Предел, который придётся закрыть импортом + +В `export.xml` у элемента `Workout` идентификатора нет вовсе — +`dogsheep/healthkit-to-sqlite` поэтому адресует тренировку **хешем содержимого** +(`hash_id` в sqlite-utils). Значит `import` снапшота задвоит тренировки, +приехавшие от HAE: та же дыра, что у точек, где её закрыли ключом +`start + end`. Сегодня импорта нет, и решать это до его формы значило бы +угадывать; предел записан в беклоге отдельной задачей. + ### Время Точка внутри объекта хранится дословно, вместе с исходной строкой даты. Для diff --git a/docs/backlog/README.md b/docs/backlog/README.md index af92c7e..647bbdd 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -21,7 +21,6 @@ - [Порядок журнала при конкурентных приёмах](poryadok-zhurnala-na-priyome.md) — доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда — живое состояние расходится с reindex ## высокий -- [Тренировки и секции с собственными id](trenirovki-i-zapisi.md) — Тренировки с геотреком и состояние разума приходят, но не разбираются — без них не закрыть ни трекер, ни агента-медика - [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое - [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может - [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате @@ -31,6 +30,7 @@ - [Словарь категориальных значений → коды HealthKit](slovar-kategorialnyh-znachenij.md) — Фазы сна и типы тренировок приходят строками русской локали — с экспортом Apple их не сверить - [Выведенные из данных схемы содержимого](samoopisanie-shemy.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке - [Импорт родного экспорта Apple Health](import-eksporta-apple.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут +- [Идентичность тренировок при импорте родного экспорта](identichnost-trenirovok-pri-importe.md) — В export.xml у тренировки нет id — импорт снапшота задвоит тренировки, приехавшие от HAE - [Проверка секций, которых поток ещё не приносил](proverka-novyh-sekcij.md) — Симптомы, ЭКГ, лекарства, цикл и вес не виденны живьём — разбор писался вслепую - [Наблюдаемость: /stats](stats-nablyudaemost.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах - [Проверка целостности собранной витрины перед подменой](celostnost-pered-podmenoj.md) — Подмена файла необратима, а годность выхода подтверждена отпечатком на ещё открытом дескрипторе diff --git a/docs/backlog/identichnost-trenirovok-pri-importe.md b/docs/backlog/identichnost-trenirovok-pri-importe.md new file mode 100644 index 0000000..1406a31 --- /dev/null +++ b/docs/backlog/identichnost-trenirovok-pri-importe.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`. diff --git a/docs/backlog/proverka-novyh-sekcij.md b/docs/backlog/proverka-novyh-sekcij.md index edb08ce..db859b4 100644 --- a/docs/backlog/proverka-novyh-sekcij.md +++ b/docs/backlog/proverka-novyh-sekcij.md @@ -36,3 +36,10 @@ секцию, которой раньше не было, теперь **фиксируется** — остаётся научиться замечать его активно: один `SELECT DISTINCT` по колонке даёт список всего, что поток приносил, и сравнение с известным набором закрывает задачу. + +Модель под секции с собственным `id` заложена (change +`2026-08-02-trenirovki-i-zapisi`): таблица `record` ключуется парой +`род + id`, и новая секция добавляется **одной строкой** в множество покрытых +имён разбора, а не миграцией. Покрыты `workouts` и `stateOfMind`; остались +`ecg`, `symptoms`, `cycleTracking`, `medications`, `heartRateNotifications` — +их формы никто не видел, и разбор вслепую сознательно не писался. diff --git a/docs/backlog/read-api-tochki.md b/docs/backlog/read-api-tochki.md index 093e194..0a36cfd 100644 --- a/docs/backlog/read-api-tochki.md +++ b/docs/backlog/read-api-tochki.md @@ -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». diff --git a/docs/backlog/retenshen-syrogo-arhiva.md b/docs/backlog/retenshen-syrogo-arhiva.md index 45c7e0a..ac290c7 100644 --- a/docs/backlog/retenshen-syrogo-arhiva.md +++ b/docs/backlog/retenshen-syrogo-arhiva.md @@ -26,14 +26,38 @@ экспорта, записи `stateOfMind` не трогаются вовсе, а `/stats` показывает глубину архива и дату снапшота, до которой он подрезан. -## Предусловие снято +## Предусловие снова открыто -Признак, без которого ретеншен был опасен, готов: доставка с непокрытой секцией -имеет статус `partial` и список непокрытых ключей -(change `2026-08-01-nerazobrannye-sekcii-dostavki`). Ретеншен обязан спрашивать -статус, а не считать `parsed` разрешением: тело `stateOfMind` восстановить -неоткуда — в экспорте Apple секции нет. +Признак «доставка с непокрытой секцией» появился в change +`2026-08-01-nerazobrannye-sekcii-dostavki` и работал заодно защитой +`stateOfMind`: такие доставки числились `partial`, и ретеншен их не тронул бы. + +Change `2026-08-02-trenirovki-i-zapisi` покрыл `stateOfMind` разбором, и защита +исчезла: доставка из одного состояния разума теперь получает `parsed` с пустым +списком непокрытых, то есть **побайтово неотличима** от доставки из метрик — а +метрики восстановимы из экспорта Apple, состояние разума нет (находка 46). +Ретеншен, написанный по правилу «удаляем всё, что не `partial`», сотрёт ровно те +тела, которых в экспорте не существует, и первая же пересборка потеряет историю +состояния разума навсегда. + +Значит признак невосстановимости нужен **не производный от «непокрытости»**. +Варианты: + +- **Перечень покрытых секций, которых нет в экспорте Apple** рядом с доставкой + (сегодня — ровно `stateOfMind`). Цена: колонка и строка в свёртке; читается + так же, как `uncovered_sections`, и одним запросом. +- **Признак у доставки «тело — единственный источник»**, выставляемый разбором. + Цена та же, но смысл шире и требует решения, что считать единственным + источником для будущих секций. +- **Никогда не подрезать тела доставок, у которых есть строки в `record`.** + Цена нулевая по схеме, но неточная: провенанс записи указывает на доставку + её **текущей** версии, а копий у записи бывает по 26. + +Рекомендация — первый вариант: он прямо отвечает на вопрос «что останется +потерянным», как это уже делает `uncovered_sections`, и не требует додумывать +семантику. Вместе с этим действует правило: задача, которая начинает разбирать секцию, тем -же изменением переводит `partial`-строки с этим ключом в `pending`. Ретеншену -позволено смотреть на `partial` только пока правило соблюдается. +же изменением переводит `partial`-строки с этим ключом в `pending` (так сделала +миграция `00007`). Ретеншену позволено смотреть на `partial` только пока правило +соблюдается. diff --git a/docs/backlog/trenirovki-i-zapisi.md b/docs/backlog/trenirovki-i-zapisi.md deleted file mode 100644 index a5d4649..0000000 --- a/docs/backlog/trenirovki-i-zapisi.md +++ /dev/null @@ -1,23 +0,0 @@ -# Тренировки и секции с собственными id - -**Приоритет:** высокий - -Тренировки приезжают с геотреком, состояние разума — с кодами HealthKit. Ни то, -ни другое сейчас не разбирается. Тренировки нужны трекеру (второй сценарий), -состояние разума — агенту-медику. - -Модель отличается от метрик: у этих сущностей есть собственный `id`, они редки, -и по часам их группировать незачем. Тренировка **перезаписывается** целиком — -она приезжает повторно, когда доедет маршрут. - -Шаги: -- миграции `workout` и `record` (секции `stateOfMind`, `ecg`, `symptoms`, - `cycleTracking`, `medications`, `heartRateNotifications` — модель одна); -- заголовок тренировки колонками, маршрут и внутренние ряды — блобом; -- пульс внутри тренировки не смешивать с метрикой `heart_rate`: разные таблицы. - -Готово, когда тренировка отдаётся одним пакетом вместе с маршрутом, а -`stateOfMind` виден записями. - -Связано: `docs/architecture.md` → «Тренировки и прочие секции». - diff --git a/docs/conventions.md b/docs/conventions.md index 033939b..febc338 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -89,8 +89,22 @@ (`internal/ident`). Сортируется по времени создания, удобен в логах и URL. Разбор внешнего id — `ident.Parse` на входной границе; синтаксически невалидный id — 404 без похода в БД. -- Естественный ключ вместо ULID там, где он есть по природе данных: `sample` - и `record` — по хешу содержимого, `workout` — по `id` из HealthKit. +- Естественный ключ вместо ULID там, где он есть по природе данных: `workout` — + по `id` из HealthKit, `record` — по паре `род секции + id` (форму + идентификатора у пяти из шести секций живьём никто не видел, и несквозной `id` + в двух секциях затёр бы одну запись другой молча). +- Новая единица хранения тем же изменением входит в **отпечаток витрины** и в + счётчики отчёта пересборки. Отпечаток отвечает «да/нет» за витрину целиком, и + единица, которой нет в счётчиках, делает расхождение безадресным: человек + видит «не совпало» при неизменившемся числе объектов и принимает по этому + необратимое решение о подмене базы. +- Правило выбора между двумя версиями одних данных объявляется либо **функцией + множества версий**, либо явно **функцией порядка журнала** — третьего + состояния нет. «Побеждает последняя пришедшая» третьим состоянием и является: + порядок свёртки порядку журнала не равен, и живая витрина расходится с + пересборкой молча. +- Любое значение из чужого JSON, попадающее в ключ, в лог или в отчёт, имеет + названный предел длины (имена непокрытых секций, `id` сущности). - Временные метки — `TEXT` в RFC 3339, **UTC**, суффикс `Z`. Фиксированная ширина сохраняет лексикографическую сортировку = хронологию. Единая точка генерации — `store.Now()`, а не дефолт в схеме: забытая вставка должна diff --git a/docs/database.md b/docs/database.md index a123b66..e91232a 100644 --- a/docs/database.md +++ b/docs/database.md @@ -29,6 +29,22 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа │ derived_layer TEXT │ └──────────────────────────────┘ │ uncovered_sections TEXT │ └────────────────────────────┘ + ┊ ┌──────────────────────────┐ ┌──────────────────────────┐ + ┊ │ workout │ │ record │ + ┊ доставка, │ ─────────────────────── │ │ ─────────────────────── │ + └┄┄┄ чья версия ┄┄┄┄▶ │ id TEXT PK│ │ kind TEXT ┐ │ + лежит сейчас │ name TEXT │ │ id TEXT ┘PK│ + │ start_utc TEXT │ │ ts_utc TEXT │ + │ end_utc TEXT │ │ tz_offset INTEGER│ + │ tz_offset INTEGER│ │ payload BLOB │ + │ duration_sec REAL? │ │ content_hash TEXT │ + │ payload BLOB │ │ delivery_id TEXT │ + │ content_hash TEXT │ │ delivery_received_at TEXT│ + │ delivery_id TEXT │ │ created_at TEXT │ + │ delivery_received_at TEXT│ │ updated_at TEXT │ + │ created_at TEXT │ └──────────────────────────┘ + │ updated_at TEXT │ + └──────────────────────────┘ ``` Связь `bucket.first_delivery_id → delivery.id` **внешним ключом не объявлена** @@ -89,3 +105,41 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа `метрика + слой + начало + конец`, у точки-измерения конец равен началу. `source` в ключ не входит: он нестабилен и переписывается задним числом. При столкновении выигрывает более полная точка, а не последняя пришедшая. + +## `workout` и `record` — сущности с собственным `id` + +Вторая единица хранения витрины. Часовой объект им не подходит: у них есть +естественный ключ, они редки (за двое суток потока — две тренировки и две +записи состояния разума при 44 и 52 доставленных копиях), и группировать их по +часам незачем. + +Таблицы две, а не одна с колонкой рода: у тренировки есть заголовок, по +которому идёт выборка (имя, интервал, длительность), а у записи его нет. Общая +таблица либо теряла бы заголовок, либо держала колонки, пустые у пяти родов из +шести. + +| Колонка | Смысл | +|---|---| +| `workout.id` | идентификатор из HealthKit. Приходит из тела и ограничен по длине разбором: уезжает и в ключ, и в записи лога | +| `record.kind` + `record.id` | ключ — **пара**. Собственный `id` наблюдался живьём только у `stateOfMind`, где он UUID; форма идентификатора остальных пяти секций не наблюдалась никем, и короткий несквозной `id` в двух разных секциях затёр бы одну запись другой молча | +| `kind` | верхнеуровневый ключ секции HAE **дословно** (`stateOfMind`, не `state_of_mind`): инвариант «форма Apple не транслируется» относится и к именам секций | +| `start_utc` / `end_utc` / `ts_utc` | UTC RFC 3339. Конец, которого нет или который не читается, равен началу: ключ — `id`, схлопывать координаты нечем, а истина остаётся в `payload` | +| `tz_offset` | смещение зоны **начала**. У `stateOfMind` всегда `0` — это значит «источник прислал UTC», а не «человек был в Гринвиче»: местной зоны у секции в потоке нет вовсе. Клиент, считающий по нему местные сутки, ошибётся | +| `duration_sec` | длительность тренировки в секундах, как прислал HAE. **`NULL` означает «источник не прислал»**: ноль — законная длительность. Не вычисляется из интервала — HAE шлёт 91.746 при интервале в 91 секунду | +| `payload` | сущность целиком исходными байтами, gzip: заголовок, маршрут, внутренние ряды и сводки. Маршрут — 95% веса тренировки, а такой JSON жмётся примерно в 25 раз. Внутрь SQL-функциями не заглянуть — та же плата, что у `bucket.payload` | +| `content_hash` | хеш канонической формы: детектор изменений, не ключ. Тренировка переприсылается каждой доставкой, пока не доедет маршрут (44 копии дают три различных содержимых) | +| `delivery_id`, `delivery_received_at` | провенанс: доставка, **чья версия лежит сейчас**, и её метка приёма. Не отчётность: по паре разрешается тай-брейк между версиями равной полноты | + +Индексы: `workout_start_utc` («заголовки тренировок за период» — основной запрос +трекера), `record_kind_ts` («записи такого-то рода за период» — единственная +форма запроса к таблице). + +**Ряд пульса внутри тренировки лежит в её `payload`, а не в объектах метрики +`heart_rate`.** Пульс приезжает дважды — в общем потоке и внутри тренировки; это +разные таблицы, и смешение задвоило бы ряд. + +**Замена версии условна.** Приехавшая побеждает, если не теряет содержания +сохранённой (множество ключей с непустым значением плюс длины верхнеуровневых +массивов); при равных наборах выигрывает версия из более поздней доставки +журнала, а не свёрнутая последней. Подробности и обоснование — в +`architecture.md`, раздел «Тренировки и прочие секции». diff --git a/docs/local-research.md b/docs/local-research.md index 54ddafa..0380714 100644 --- a/docs/local-research.md +++ b/docs/local-research.md @@ -1658,6 +1658,73 @@ apple_stand_time 14 Отсюда статус `partial` и колонка `delivery.uncovered_sections`: статус отвечает на вопрос «разобрано ли всё», список — «что именно осталось». +## 51. Тренировка досчитывается задним числом, но поля у неё только прибывают + +Замер по всем 118 доставкам архива, группировка элементов секций по `id`: + +| сущность | копий | различных содержимых | набор полей рос | набор полей убывал | +|---|---:|---:|---|---| +| тренировка A | 26 | 3 | да | нет | +| тренировка B | 18 | 1 | — | — | +| `stateOfMind` #1 | 26 | 1 | — | — | +| `stateOfMind` #2 | 26 | 1 | — | — | + +Что менялось у тренировки A между версиями: + +``` +версия 0 → 1 +stepCadence, +stepCount, изменилось значение ряда activeEnergy +версия 1 → 2 набор полей тот же, изменились totalEnergy и basalEnergy +``` + +Два вывода, и оба вошли в правило замены версии. + +**Тренировка правится задним числом ровно так же, как минутное ведро** +(находка 10): при неизменном наборе полей значения досчитываются. Значит +правило «при равной полноте побеждает тот, чья каноническая форма меньше» — +то, что действует для точек, — заморозило бы тренировку на произвольной версии +навсегда, вместе с недосчитанной энергией. + +**Набор полей за весь корпус ни разу не уменьшился.** Обеднённая версия — +событие, которого поток не производит; но маршрут это 95% веса тренировки +(находка 22), а восстановление требует пересборки всего журнала. Поэтому +удержание сохранённой версии стоит одного сравнения множеств, а событие делается +наблюдаемым — счётчиком и `WARN`, — вместо необратимого. + +**Правило полноты, написанное для точек, здесь неприменимо.** Оно требует, чтобы +значения общих содержательных ключей совпали, иначе отношение включения гасится +до «равенства». У точки это верно (надмножество имён при других значениях +означает другое измерение), у сущности — нет: значения между версиями +расходятся всегда. Проверено на копии пакета `canon`: + +``` +сохранённая с маршрутом vs обеднённая, значения общих полей те же : superset +сохранённая с маршрутом vs обеднённая, значения общих полей иные : equal +сохранённая vs версия с усечённым маршрутом (2 точки → 1) : equal +``` + +Отсюда же второй разряд правила: усечённый ряд ключа не теряет, поэтому +сравнивается ещё и длина верхнеуровневых массивов. + +## 52. Половина потока — не `metrics`: перемер на 118 доставках + +Пересчёт находки 50 на выросшем корпусе. Набор верхнеуровневых ключей `data`: + +| набор ключей `data` | доставок | +|---|---| +| `metrics` | 65 | +| `workouts` | 27 | +| `stateOfMind` | 26 | + +Пропорция та же, что была на 99 доставках (51/24/24), и наблюдение «ни одна +доставка не несла двух секций сразу» держится: автоматизация HAE шлёт одну +секцию за раз. Полагаться на это в правилах удаления данных по-прежнему нельзя — +за двое суток наблюдения смешанная доставка просто не успела бы случиться. + +С покрытием `workouts` и `stateOfMind` разбором эти 53 доставки перестали быть +`partial`. Прогон живого архива после изменения: 118 тел, свёрнуто 118, отказов +ноль, частично разобранных ноль, в витрине 2049 часовых объектов, 2 тренировки и +2 записи; повторное проигрывание дало тот же отпечаток. + ## Инструмент Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная diff --git a/internal/canon/canon.go b/internal/canon/canon.go index 820631a..5896219 100644 --- a/internal/canon/canon.go +++ b/internal/canon/canon.go @@ -246,6 +246,72 @@ func (f Fields) Relate(g Fields) Fullness { 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 говорит, совпадают ли значения ключей, содержательных у обеих // точек. Сравнение каноническое: порядок ключей и дребезг последнего разряда // расхождением не считаются. diff --git a/internal/fold/fold.go b/internal/fold/fold.go index 1925126..cc5c347 100644 --- a/internal/fold/fold.go +++ b/internal/fold/fold.go @@ -65,26 +65,28 @@ func New(arch *archive.Archive, st *store.Store, maxBody int64, log *slog.Logger const finishTimeout = 10 * time.Second // Stats — итог свёртки одной доставки. +// +// Счётчики слияния ВСТРОЕНЫ, а не переписаны полем в поле: ручное копирование +// молча теряет новый счётчик, а по одному из них (удержанная обеднённая версия +// сущности) принято решение не объединять поля — забытая строка присваивания +// отменила бы наблюдение при зелёных тестах хранилища. type Stats struct { + store.MergeStats + Metrics int Points int - Stored int - Buckets int - Unchanged int - Overwrites int - Incomparable int - SealedHits int - UnitsConflicts int SkippedNoTime int SkippedMalformed int SkippedBadEnd int - Layer string - LayerMismatch bool - Collisions []store.Collision - IncomparableAt []store.Collision + // Счётчики пропуска сущностей: у каждого класса свой, потому что тело в + // архиве остаётся, а вернуть сущность может только пересборка. + SkippedNoID int + SkippedEntityNoTime int + SkippedEntityMalformed int + Layer string + LayerMismatch bool // Uncovered — верхнеуровневые ключи `data`, которых разбор не покрывает. - // Это ответ на вопрос «что останется потерянным, если тело удалить»: - // для stateOfMind он необратим — в экспорте Apple этой секции нет. + // Это ответ на вопрос «что останется потерянным, если тело удалить». Uncovered []string // UncoveredDropped — сколько имён отброшено границей списка. UncoveredDropped int @@ -158,24 +160,21 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err stats.SkippedNoTime = parsed.SkippedNoTime stats.SkippedMalformed = parsed.SkippedMalformed stats.SkippedBadEnd = parsed.SkippedBadEnd + stats.SkippedNoID = parsed.SkippedNoID + stats.SkippedEntityNoTime = parsed.SkippedEntityNoTime + stats.SkippedEntityMalformed = parsed.SkippedEntityMalformed stats.Layer = string(parsed.Layer) 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 { s.fail(ctx, deliveryID, err, parsed.Uncovered) return stats, err } - - 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.MergeStats = merge // Источник истины — список; статус производен от него и от факта отказа. // Приоритет назван явно, иначе два будущих читателя (ретеншен и /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) { skipped := st.SkippedNoTime + st.SkippedMalformed + st.SkippedBadEnd + skippedEntities := st.SkippedNoID + st.SkippedEntityNoTime + st.SkippedEntityMalformed attrs := []any{ "delivery_id", deliveryID, @@ -228,6 +228,16 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) { "skipped_no_time", st.SkippedNoTime, "skipped_malformed", st.SkippedMalformed, "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_mismatch", st.LayerMismatch, // Структурным []string, а не склейкой: JSON-кодировщик slog экранирует @@ -242,13 +252,26 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) { if len(st.IncomparableAt) > 0 { 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. allSkipped := st.Points == 0 && skipped > 0 + // То же для сущностей: доставка из одних тренировок, у которой не осталось + // ни одной, — это сменившийся формат, а не пустая секция. + allEntitiesSkipped := st.Workouts == 0 && st.Records == 0 && skippedEntities > 0 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: // Не частичный разбор, а тело, не похожее на HAE: секций у HAE восемь, // а границу выбило больше тридцати двух. @@ -288,6 +311,16 @@ func formatCollisions(cs []store.Collision) string { 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 — значение слоя, означающее «оставить как было». const keepLayer = "" @@ -379,10 +412,10 @@ func (s *Service) readBody(rawPath string) ([]byte, error) { return body, nil } -func toIncoming(points []hae.Point) []store.IncomingPoint { - out := make([]store.IncomingPoint, 0, len(points)) - for _, p := range points { - out = append(out, store.IncomingPoint{ +func toIncoming(parsed hae.Result) store.Incoming { + points := make([]store.IncomingPoint, 0, len(parsed.Points)) + for _, p := range parsed.Points { + points = append(points, store.IncomingPoint{ Metric: p.Metric, Layer: string(p.Layer), 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 } diff --git a/internal/fold/fold_test.go b/internal/fold/fold_test.go index 14bfe16..58743b0 100644 --- a/internal/fold/fold_test.go +++ b/internal/fold/fold_test.go @@ -257,8 +257,8 @@ func TestFoldЧастичныйРазборВиденВУчёте(t *testing.T) if d.ParseStatus != store.ParsePartial { t.Errorf("статус %q, ожидался %q", d.ParseStatus, store.ParsePartial) } - if !strings.Contains(d.UncoveredSections, "stateOfMind") { - t.Errorf("список в базе %q не содержит stateOfMind", d.UncoveredSections) + if !strings.Contains(d.UncoveredSections, "ecg") { + t.Errorf("список в базе %q не содержит непокрытой секции", d.UncoveredSections) } // Точки метрик обязаны сохраниться: частичность не отменяет разобранного. if stats.Points == 0 { diff --git a/internal/fold/log_test.go b/internal/fold/log_test.go index ecaab03..fba437f 100644 --- a/internal/fold/log_test.go +++ b/internal/fold/log_test.go @@ -221,7 +221,7 @@ func TestFoldЧастичныйРазборВЛоге(t *testing.T) { {"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:09:00 +0300","qty":10}]}], - "stateOfMind":[{"valence":"СЕКРЕТНОЕ-НАСТРОЕНИЕ"}]}}` + "ecg":[{"classification":"СЕКРЕТНЫЙ-РИТМ"}]}}` deliver(t, arch, st, "d1", "Minutes", "a1", []byte(body)) if _, err := f.Fold(ctx, "d1"); err != nil { @@ -229,10 +229,10 @@ func TestFoldЧастичныйРазборВЛоге(t *testing.T) { } out := buf.String() - if !strings.Contains(out, "stateOfMind") { + if !strings.Contains(out, "ecg") { t.Error("имени непокрытой секции нет в логе — момент появления новой секции незаметен") } - if strings.Contains(out, "СЕКРЕТНОЕ-НАСТРОЕНИЕ") { + if strings.Contains(out, "СЕКРЕТНЫЙ-РИТМ") { t.Error("содержимое непокрытой секции утекло в лог") } @@ -250,7 +250,132 @@ func TestFoldЧастичныйРазборВЛоге(t *testing.T) { if rec.Level != "INFO" { t.Errorf("уровень %q, ожидался INFO: частичность — не отклонение", rec.Level) } - if len(rec.Uncovered) != 1 || rec.Uncovered[0] != "stateOfMind" { - t.Errorf("атрибут uncovered = %v, ожидался структурный список из stateOfMind", rec.Uncovered) + if len(rec.Uncovered) != 1 || rec.Uncovered[0] != "ecg" { + 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) } } diff --git a/internal/hae/entity.go b/internal/hae/entity.go new file mode 100644 index 0000000..ae77cf9 --- /dev/null +++ b/internal/hae/entity.go @@ -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 +} diff --git a/internal/hae/entity_test.go b/internal/hae/entity_test.go new file mode 100644 index 0000000..dd16d5f --- /dev/null +++ b/internal/hae/entity_test.go @@ -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) + } +} diff --git a/internal/hae/hae.go b/internal/hae/hae.go index 407ab3a..07c16fb 100644 --- a/internal/hae/hae.go +++ b/internal/hae/hae.go @@ -95,12 +95,66 @@ type Point struct { 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 — итог разбора доставки. Частичные исходы живут в счётчиках, а не в // ошибке: пакет, у которого не разобралась одна точка из тысячи, — обычное // дело, и терять из-за неё остальное нельзя. type Result struct { Points []Point + // Workouts и Records — сущности с собственным идентификатором. Разведены, + // потому что у тренировки есть заголовок (имя, интервал, длительность), по + // которому идёт выборка, а у записи его нет. + Workouts []Entity + Records []Entity + + // SkippedNoID — сущности без пригодного идентификатора: пустого, нет вовсе + // или длиннее предела. Один счётчик на все три случая: исход у них общий, а + // различает их только тело, лежащее в архиве. + SkippedNoID int + // SkippedEntityNoTime — сущности с идентификатором, но без разбираемой + // метки времени. + SkippedEntityNoTime int + // SkippedEntityMalformed — элементы секции, не разобравшиеся как объект. + SkippedEntityMalformed int + // Uncovered — верхнеуровневые ключи `data`, которых разбор не покрывает, // отсортированные и без повторов. Половина живого потока состоит из таких // доставок целиком (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 { return Result{}, err } - res.Uncovered = uncovered - res.UncoveredDropped = dropped + res.Uncovered = env.uncovered + 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) if len(metrics) == 0 { return res, nil @@ -209,6 +267,12 @@ func Parse(body []byte, meta Meta) (res Result, err error) { // определился слой, обязана остаться записью о том, что в теле есть // невосстановимая секция. Иначе ретеншен увидит failed без списка и // решит, что терять нечего. + // + // Сущности при этом НЕ отдаются, хотя слоя у них нет и разобрались они + // успешно. «Всё или ничего» относится к доставке, а не к точкам: отдай + // мы их, доставка получила бы `failed` при частично записанной витрине, + // и повторная свёртка перестала бы быть no-op. Цена названа в спеке — + // такая доставка доедет пересборкой, а тело ждёт в архиве. return Result{ Metrics: res.Metrics, Uncovered: res.Uncovered, @@ -262,16 +326,53 @@ type group struct { // 42 МиБ через map[string]any удерживает 197 МиБ кучи против 54 МиБ у этой // формы. Вместе с самим телом пик доходил бы до ~300 МиБ на доставку — это // OOM ровно на пике потока, когда терять доставки дороже всего. -// metricsSection — единственная секция, которую разбор покрывает сегодня. -const metricsSection = "metrics" +// Секции, которые разбор покрывает. Прочие секции с собственными `id` (`ecg`, +// `symptoms`, `cycleTracking`, `medications`, `heartRateNotifications`) +// покрытыми намеренно не становятся: живой поток не приносил их ни разу, их +// форма никем не наблюдалась, а полнота покрытия HealthKit ради полноты целью +// проекта не является. +const ( + metricsSection = "metrics" + workoutsSection = "workouts" + stateOfMindSection = "stateOfMind" +) -// covered говорит, покрывает ли разбор секцию с таким именем. +// decodeCovered разбирает секцию, если разбор её покрывает; второй возврат +// говорит, взялся ли он за неё. // -// Функция, а не изменяемая карта: разбор и перечисление непокрытых ходят по -// одному источнику, поэтому состояние «секция разбирается, но числится -// непокрытой» невыразимо. -func covered(section string) bool { - return section == metricsSection +// Один источник и для разбора, и для перечисления непокрытых: перечисляющий +// спрашивает ровно того, кто разбирает, поэтому состояние «секция разбирается, +// но числится непокрытой» невыразимо по построению. Отдельный предикат +// `covered` разошёлся бы с этим switch при первой же новой секции. +// +// Повтор ключа покрытой секции 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 дешевле по коду, но // копирует байты ВСЕХ секций и держит их до конца разбора; у проглатывания // копия одна и живёт до следующего члена. -func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string, dropped int, err error) { - fail := func(e error) ([]metricEnvelope, []string, int, error) { - return nil, nil, 0, fmt.Errorf("%w: %v", ErrMalformed, e) //nolint:errorlint // причина уходит в лог, наружу не раскрывается +func decodeEnvelope(body []byte) (envelope, error) { + var env envelope + + fail := func(e error) (envelope, error) { + return envelope{}, fmt.Errorf("%w: %v", ErrMalformed, e) //nolint:errorlint // причина уходит в лог, наружу не раскрывается } dec := json.NewDecoder(bytes.NewReader(body)) @@ -335,7 +438,7 @@ func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string, // раскладывал его в пустую структуру. Границы поведения этой задачей не // двигаются — она добавляет список, а не строгость. if tok == nil { - return nil, nil, 0, nil + return envelope{}, nil } if d, ok := tok.(json.Delim); !ok || d != '{' { return fail(fmt.Errorf("ожидался объект, встречено %v", tok)) @@ -352,8 +455,12 @@ func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string, } continue } - metrics, uncovered, dropped, err = decodeData(dec, seen) - if err != nil { + // Повтор самого члена `data` JSON допускает, и результаты + // НАКАПЛИВАЮТСЯ, а не замещаются: присваивание теряло бы секции первого + // члена целиком, причём молча — их имена уже отмечены в `seen` и во + // второй список непокрытых не попали бы. Правило то же, что уровнем + // ниже для повтора ключа секции. + if err := decodeData(dec, seen, &env); err != nil { return fail(err) } } @@ -363,60 +470,75 @@ func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string, // Список канонизируется: порядок ключей в JSON от HAE нестабилен, а // значение уезжает в базу и сравнивается между доставками. - sort.Strings(uncovered) - return metrics, uncovered, dropped, nil + sort.Strings(env.uncovered) + return env, nil } -// decodeData разбирает объект data, собирая metrics и имена непокрытых секций. -func decodeData(dec *json.Decoder, seen map[string]struct{}) ([]metricEnvelope, []string, int, error) { +// envelope — что разбор вынул из тела: покрытые секции и имена непокрытых. +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() if err != nil { - return nil, nil, 0, err + return err } // data не объект — прежнее поведение: ошибка ровно там, где была. 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() { name, err := memberName(dec) if err != nil { - return nil, nil, 0, err + return err } - if covered(name) { - // Повтор ключа metrics JSON допускает; секции ОБЪЕДИНЯЮТСЯ, а не - // побеждает последняя: терять точки молча нельзя. - var part []metricEnvelope - if err := dec.Decode(&part); err != nil { - return nil, nil, 0, err - } - metrics = append(metrics, part...) + handled, err := decodeCovered(name, dec, env) + if err != nil { + return err + } + if handled { continue } if err := swallow(dec); err != nil { - return nil, nil, 0, err + return err } if _, dup := seen[name]; dup { continue } seen[name] = struct{}{} - if len(uncovered) >= maxUncovered { - dropped++ + if len(env.uncovered) >= maxUncovered { + env.dropped++ continue } - uncovered = append(uncovered, clipSection(name)) + env.uncovered = append(env.uncovered, clipSection(name)) } 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() отдаёт имя уже после разбора diff --git a/internal/hae/hae_test.go b/internal/hae/hae_test.go index 18f5273..f9a8b8f 100644 --- a/internal/hae/hae_test.go +++ b/internal/hae/hae_test.go @@ -548,10 +548,11 @@ func FuzzParse(f *testing.F) { }) } -// Половина живого потока состоит из непокрытых секций целиком (48 доставок из -// 99: workouts и stateOfMind). Без списка они неотличимы от разобранной -// доставки с пустой секцией метрик, и ретеншен, ориентируясь на статус, срезал -// бы тела, которые для stateOfMind единственный источник. +// Непокрытая секция неотличима от разобранной доставки с пустой секцией +// метрик, если её не назвать: ретеншен, ориентируясь на статус, срезал бы +// тела, которые для секций без экспорта Apple единственный источник. С тех пор +// как workouts и stateOfMind стали покрытыми, роль непокрытой в фикстуре +// играют секции, которых поток ещё не приносил. func TestParseПеречисляетНепокрытыеСекции(t *testing.T) { t.Parallel() @@ -560,7 +561,7 @@ func TestParseПеречисляетНепокрытыеСекции(t *testing. t.Fatalf("разбор: %v", err) } - want := []string{"stateOfMind", "workouts"} + want := []string{"ecg", "symptoms"} if !slices.Equal(res.Uncovered, want) { t.Errorf("непокрытые %v, ожидались %v", res.Uncovered, want) } @@ -578,11 +579,11 @@ func TestParseНепокрытыеСекцииГраницыИДетермини t.Run("доставка из одной непокрытой секции", func(t *testing.T) { 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 { t.Fatalf("разбор: %v", err) } - if !slices.Equal(res.Uncovered, []string{"stateOfMind"}) { + if !slices.Equal(res.Uncovered, []string{"ecg"}) { t.Errorf("непокрытые %v", res.Uncovered) } if len(res.Points) != 0 { @@ -609,11 +610,11 @@ func TestParseНепокрытыеСекцииГраницыИДетермини t.Parallel() bodies := []string{ - `{"data":{"workouts":[],"stateOfMind":[],"ecg":[]}}`, - `{"data":{"ecg":[],"workouts":[],"stateOfMind":[]}}`, - `{"data":{"stateOfMind":[],"ecg":[],"workouts":[],"ecg":[]}}`, + `{"data":{"symptoms":[],"medications":[],"ecg":[]}}`, + `{"data":{"ecg":[],"symptoms":[],"medications":[]}}`, + `{"data":{"medications":[],"ecg":[],"symptoms":[],"ecg":[]}}`, } - want := []string{"ecg", "stateOfMind", "workouts"} + want := []string{"ecg", "medications", "symptoms"} for _, b := range bodies { res, err := hae.Parse([]byte(b), hae.Meta{}) if err != nil { diff --git a/internal/hae/mem_test.go b/internal/hae/mem_test.go index 50afd0e..1cabb43 100644 --- a/internal/hae/mem_test.go +++ b/internal/hae/mem_test.go @@ -116,6 +116,13 @@ func TestParseУдержаниеКучиНепокрытойСекции(t *test t.Logf("непокрытых %v, удержано %d МиБ при теле %d МиБ", res.Uncovered, retained>>20, len(body)>>20) + // Замер обязан идти по ветке проглатывания. Без этой проверки расширение + // множества покрытых секций превращает сторож в зелёную пустышку — что уже + // однажды и произошло. + if len(res.Uncovered) == 0 { + t.Fatal("непокрытых секций нет — замер идёт мимо проглатывания и ничего не сторожит") + } + if retained > limit { t.Errorf("удержано %d МиБ при теле %d МиБ — больше четырёх тел; "+ "похоже, секции удерживаются, а не проглатываются", @@ -149,9 +156,13 @@ func bodyWithUncovered(size int) []byte { var b strings.Builder b.WriteString(`{"data":{"metrics":[{"name":"m","units":"count","data":[`) 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 { b.WriteString(entry) } diff --git a/internal/hae/testdata/README.md b/internal/hae/testdata/README.md index 4f80b8f..a6c3cff 100644 --- a/internal/hae/testdata/README.md +++ b/internal/hae/testdata/README.md @@ -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`) часовая — классификация **по метрике**, а не по доставке; редкие наследуют минутный слой; суточная сводка сна | @@ -20,7 +33,12 @@ | `mixed.json` | одна доставка с минутными, посекундными и часовыми метриками — перенастройка автоматизации | | `heartbeat_series.json` | точка с `heartbeatSeries`: третий формат времени, серия проходит исходными байтами | | `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_entities.json` | краевые случаи сущностей: пустой, отсутствующий и слишком длинный `id`; неразбираемая метка и её отсутствие; неразбираемый `end`; нечисловая и отсутствующая длительность; две версии одного `id` в одном теле; элемент, не являющийся объектом | `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 \ --out internal/hae/testdata/<имя>.json ``` + +`--limit` режет и внутренние ряды тренировки (маршрут, пульс, энергия): фикстуре +нужна форма точки ряда, а не 593 её экземпляра. diff --git a/internal/hae/testdata/handmade_entities.json b/internal/hae/testdata/handmade_entities.json new file mode 100644 index 0000000..5d13b1a --- /dev/null +++ b/internal/hae/testdata/handmade_entities.json @@ -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": [] + } + ] + } +} diff --git a/internal/hae/testdata/state_of_mind.json b/internal/hae/testdata/state_of_mind.json new file mode 100644 index 0000000..e12e00e --- /dev/null +++ b/internal/hae/testdata/state_of_mind.json @@ -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" + ] + } + ] + } +} diff --git a/internal/hae/testdata/uncovered_sections.json b/internal/hae/testdata/uncovered_sections.json index 4c96a15..279a2bd 100644 --- a/internal/hae/testdata/uncovered_sections.json +++ b/internal/hae/testdata/uncovered_sections.json @@ -5,16 +5,56 @@ "name": "step_count", "units": "count", "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: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"} + { + "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: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", "duration": 1800, "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": [ - {"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", "kind": "momentary_emotion", "valence": 0.25, - "labels": ["slightly_pleasant"], + "labels": [ + "slightly_pleasant" + ], "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 шлёт одну секцию за раз." } diff --git a/internal/hae/testdata/workout_indoor.json b/internal/hae/testdata/workout_indoor.json new file mode 100644 index 0000000..791cccb --- /dev/null +++ b/internal/hae/testdata/workout_indoor.json @@ -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" + } + } + } + ] + } +} diff --git a/internal/hae/testdata/workout_route.json b/internal/hae/testdata/workout_route.json new file mode 100644 index 0000000..fda3a1a --- /dev/null +++ b/internal/hae/testdata/workout_route.json @@ -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" + } + ] + } +} diff --git a/internal/replay/archive_test.go b/internal/replay/archive_test.go index 4455252..00b31bb 100644 --- a/internal/replay/archive_test.go +++ b/internal/replay/archive_test.go @@ -83,12 +83,21 @@ func TestReplayЖивогоАрхива(t *testing.T) { t.Fatal("ни одна доставка не свернулась") } - // Половина живого потока не несёт metrics вовсе (находка 50): такие - // доставки обязаны быть отличимы от разобранных целиком, иначе ретеншен - // срежет тела, которые для stateOfMind единственный источник. - if first.Partial == 0 { - t.Error("ни одной частично разобранной доставки — перечисление непокрытых секций не работает") + // Половина живого потока не несёт metrics вовсе (находка 50): это + // тренировки и состояние разума. С тех пор как обе секции покрыты, + // частично разобранных доставок в архиве нет — и вместо них проверяется то, + // ради чего покрытие делалось: сущности доехали до витрины. + // + // Проверяется свойство, а не число: корпус растёт с каждой доставкой, а + // прогон живого архива в гейт не входит, так что протухшая константа + // покраснела бы молча. + 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 { 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 { t.Errorf("повторное проигрывание изменило содержимое объектов:\n %s\n %s", first.Fingerprint, second.Fingerprint) diff --git a/internal/replay/replay.go b/internal/replay/replay.go index dc0d28a..a47f8b7 100644 --- a/internal/replay/replay.go +++ b/internal/replay/replay.go @@ -75,7 +75,12 @@ type Report struct { // исходов разошлись бы при первой же правке классификации. Outcome - Buckets int64 + Buckets int64 + // Workouts и Records — остальные единицы хранения витрины. Считаются рядом + // с объектами потому, что отпечаток отвечает «да/нет» за витрину целиком, а + // решение о подмене базы необратимо и требует направления расхождения. + Workouts int64 + Records int64 Fingerprint string // Canceled — проигрывание прервано отменой, а не дошло до конца. @@ -171,6 +176,14 @@ func Run(ctx context.Context, o Options) (Report, error) { if err != nil { 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) if err != nil { return stopOr(rep, err) @@ -190,7 +203,9 @@ func Run(ctx context.Context, o Options) (Report, error) { "failed_other", rep.FailedOther, "partial", rep.Partial, "incomparable", rep.Incomparable, - "buckets", rep.Buckets) + "buckets", rep.Buckets, + "workouts", rep.Workouts, + "records", rep.Records) return rep, nil } diff --git a/internal/store/bucket.go b/internal/store/bucket.go index ab3d6ec..b67f663 100644 --- a/internal/store/bucket.go +++ b/internal/store/bucket.go @@ -72,6 +72,21 @@ type MergeStats struct { Collisions []Collision // IncomparableAt — то же для несравнимых наборов. IncomparableAt []Collision + + // Workouts и Records — сколько сущностей пришло с доставкой. + Workouts int + Records int + // WorkoutsWritten и RecordsWritten — сколько из них действительно легло в + // витрину. Разница с пришедшими — работа хеша-детектора: тренировка + // переприсылается каждой доставкой, пока не доедет маршрут. + WorkoutsWritten int + RecordsWritten int + // EntitiesHeld — приехавшие версии, отклонённые как теряющие содержание + // сохранённой (включая несравнимые наборы). Это и есть плата за отказ + // объединять поля: событие считается, а не предотвращается молча. + EntitiesHeld int + // HeldAt — координаты первых таких сущностей, для записи в лог. + HeldAt []EntityRef } // Collision — координаты объекта, где столкновение разрешилось перезаписью @@ -102,35 +117,58 @@ func clipMetric(metric string) string { return metric[:maxMetricInLog] + "…" } -// MergePoints раскладывает точки по часовым объектам и сливает их с -// сохранёнными. +// Merge раскладывает всё, что дала доставка, по витрине: точки — по часовым +// объектам, сущности — по своим таблицам. // // Час берётся по НАЧАЛУ точки: интервал пересекает границы часов, и любой // другой выбор сделал бы принадлежность объекту зависящей от длительности. -// Вся доставка сворачивается ОДНОЙ транзакцией, а не транзакцией на объект. +// Вся доставка сворачивается ОДНОЙ транзакцией, а не транзакцией на объект и +// не отдельной транзакцией на сущности. // // Транзакция на объект давала недетерминированное частичное состояние: обход // карты групп рандомизирован, и при отказе посреди доставки набор уже // закоммиченных объектов каждый раз другой (измерено: восемь прогонов одной // доставки — семь разных состояний). Это ломает инвариант «состояние // пересобираемо»: пересборка из архива давала бы не то, что живой приём, а -// хеш-детектор после расхождения переписывал бы «неизменившееся». +// хеш-детектор после расхождения переписывал бы «неизменившееся». Наблюдение +// «секции не смешиваются в одной доставке» собрано за двое суток и основанием +// для второй транзакции не является. // // Заодно снимается стоимость: отдельный коммит на объект стоил около 0.7 мс, // то есть 8.5 мс на килобайт тела. -func (s *Store) MergePoints(ctx context.Context, in []IncomingPoint, deliveryID string) (MergeStats, error) { - groups := groupByHour(in) +func (s *Store) Merge(ctx context.Context, in Incoming, from DeliveryRef) (MergeStats, error) { + groups := groupByHour(in.Points) 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 - 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 { group := groups[key] - res, err := mergeBucket(ctx, tx, key, group, deliveryID) + res, err := mergeBucket(ctx, tx, key, group, from.ID) if err != nil { return err } @@ -158,6 +196,23 @@ func (s *Store) MergePoints(ctx context.Context, in []IncomingPoint, deliveryID 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 }) if err != nil { @@ -591,27 +646,44 @@ func encodePayload(points []Point) ([]byte, error) { return nil, fmt.Errorf("сериализация точек: %w", err) } + return gzipBytes(raw.Bytes()) +} + +// gzipBytes и gunzipBytes — единственное кодирование содержимого витрины, +// общее для часового объекта и для сущности с собственным `id`. Второй кадр +// упаковки разошёлся бы с первым при первой же правке — например, забытым +// Close, который оставляет усечённый блоб. +func gzipBytes(raw []byte) ([]byte, error) { var buf bytes.Buffer gz := gzip.NewWriter(&buf) - if _, err := gz.Write(raw.Bytes()); err != nil { - return nil, fmt.Errorf("сжатие точек: %w", err) + if _, err := gz.Write(raw); err != nil { + return nil, fmt.Errorf("сжатие содержимого: %w", err) } + // Close дописывает хвост gzip; без него блоб читается лишь частично. if err := gz.Close(); err != nil { return nil, fmt.Errorf("закрытие gzip: %w", err) } return buf.Bytes(), nil } -func decodePayload(payload []byte) ([]Point, error) { +func gunzipBytes(payload []byte) ([]byte, error) { gz, err := gzip.NewReader(bytes.NewReader(payload)) if err != nil { - return nil, fmt.Errorf("распаковка точек: %w", err) + return nil, fmt.Errorf("распаковка содержимого: %w", err) } defer func() { _ = gz.Close() }() raw, err := io.ReadAll(gz) 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 @@ -700,7 +772,7 @@ func (s *Store) CountBuckets(ctx context.Context) (int64, error) { } // 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) { + 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 = ` SELECT metric, layer, hour_utc, content_hash, points, units, sealed FROM bucket ORDER BY metric, layer, hour_utc` - rows, err := s.db.QueryContext(ctx, q) + rows, err := tx.QueryContext(ctx, q) if err != nil { - return "", fmt.Errorf("select buckets: %w", err) + return fmt.Errorf("select buckets: %w", err) } defer func() { _ = rows.Close() }() - h := sha256.New() for rows.Next() { var metric, layer, hour, hash, units string var points int var sealed bool 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 и по той же причине. - fmt.Fprintf(h, "%d:%s|%d:%s|%s|%s|%d|%d:%s|%t\n", - len(metric), metric, len(layer), layer, hour, hash, points, len(units), units, sealed) + fmt.Fprintf(h, "%s|%d:%s|%d:%s|%s|%s|%d|%d:%s|%t\n", + fpBucket, len(metric), metric, len(layer), layer, hour, hash, points, + len(units), units, sealed) } 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. diff --git a/internal/store/bucket_test.go b/internal/store/bucket_test.go index c01109a..5cb9fec 100644 --- a/internal/store/bucket_test.go +++ b/internal/store/bucket_test.go @@ -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() 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}`), } - 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 { 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() 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}`), } - 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) } - 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) } @@ -125,7 +125,7 @@ func TestMergePointsДозаписьНеТеряетСохранённое(t *te // Хеш — детектор изменений: повтор того же часа не пишет в базу. Именно это // делает широкие проходы синхронизации дешёвыми. -func TestMergePointsПовторНеПишет(t *testing.T) { +func TestMergeПовторНеПишет(t *testing.T) { t.Parallel() 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"}`), } - 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) } 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) } - stats, err := st.MergePoints(ctx, in, "d2") + stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d2"}) if err != nil { t.Fatalf("повторное слияние: %v", err) } @@ -172,7 +172,7 @@ func TestMergePointsПовторНеПишет(t *testing.T) { // Порядок точек внутри доставки нестабилен (находка 2), и идентичность не // имеет права от него зависеть. -func TestMergePointsИдемпотентенКПорядкуТочек(t *testing.T) { +func TestMergeИдемпотентенКПорядкуТочек(t *testing.T) { t.Parallel() st := open(t) @@ -185,7 +185,7 @@ func TestMergePointsИдемпотентенКПорядкуТочек(t *testin } 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) } 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) } - stats, err := st.MergePoints(ctx, reversed, "d2") + stats, err := st.Merge(ctx, store.Incoming{Points: reversed}, store.DeliveryRef{ID: "d2"}) if err != nil { t.Fatalf("слияние в обратном порядке: %v", err) } @@ -213,7 +213,7 @@ func TestMergePointsИдемпотентенКПорядкуТочек(t *testin // Ключ по метке схлопнул бы эти три записи в одну. Живьём такое встречается в // 22 доставках из 94 (находка 47), причём внутри одной доставки — там тай-брейк // по времени приёма неприменим в принципе. -func TestMergePointsЗаписиСОднойМеткойНеСхлопываются(t *testing.T) { +func TestMergeЗаписиСОднойМеткойНеСхлопываются(t *testing.T) { t.Parallel() 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":"В кровати"}`), } - 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) } @@ -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 { t.Fatalf("повтор: %v", err) } @@ -250,7 +250,7 @@ func TestMergePointsЗаписиСОднойМеткойНеСхлопывают // Эпизод, пересекающий границу часа, ложится в объект по НАЧАЛУ: любой другой // выбор сделал бы принадлежность объекту зависящей от длительности. -func TestMergePointsЧасПоНачалуИнтервала(t *testing.T) { +func TestMergeЧасПоНачалуИнтервала(t *testing.T) { t.Parallel() st := open(t) @@ -259,7 +259,7 @@ func TestMergePointsЧасПоНачалуИнтервала(t *testing.T) { in := []store.IncomingPoint{ 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) } @@ -274,7 +274,7 @@ func TestMergePointsЧасПоНачалуИнтервала(t *testing.T) { // Правило разрешения столкновений: выигрывает более полная точка, а не // последняя пришедшая. Иначе бедная доставка стирает у богатой поля, которых // сама не несёт. -func TestMergePointsБеднаяТочкаНеСтираетБогатую(t *testing.T) { +func TestMergeБеднаяТочкаНеСтираетБогатую(t *testing.T) { t.Parallel() 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", `{"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) } - 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 { t.Fatalf("второе слияние: %v", err) } @@ -311,7 +311,7 @@ func TestMergePointsБеднаяТочкаНеСтираетБогатую(t *te // Полнота — множество ключей, а не их число. Счётчик значащих полей давал // сохранённой точке 5 против 2 и стирал настоящее измерение безвозвратно: // восстановить его можно было бы только из сырого архива, пока он жив. -func TestMergePointsПоляБезСодержанияНеСтираютИзмерение(t *testing.T) { +func TestMergeПоляБезСодержанияНеСтираютИзмерение(t *testing.T) { t.Parallel() 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", `{"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) } - 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 { t.Fatalf("второе слияние: %v", err) } @@ -345,7 +345,7 @@ func TestMergePointsПоляБезСодержанияНеСтираютИзме // Поле с нулевым значением содержания не несёт, но и теряться не должно: при // равном множестве содержательных ключей выигрывает точка со всеми ключами. -func TestMergePointsРавноеСодержаниеНеТеряетПоля(t *testing.T) { +func TestMergeРавноеСодержаниеНеТеряетПоля(t *testing.T) { t.Parallel() 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", `{"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) } - 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) } @@ -375,7 +375,7 @@ func TestMergePointsРавноеСодержаниеНеТеряетПоля(t * // Несравнимые наборы полей на живом потоке не встретились ни разу (0 из 2 897 // столкновений), поэтому объединение полей не реализовано. Взамен — наблюдение: // счётчик и координаты объекта, по которым событие можно будет разобрать. -func TestMergePointsНесравнимыеНаборыСчитаются(t *testing.T) { +func TestMergeНесравнимыеНаборыСчитаются(t *testing.T) { t.Parallel() 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", `{"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) } - 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 { t.Fatalf("второе слияние: %v", err) } @@ -420,7 +420,7 @@ func TestMergePointsНесравнимыеНаборыСчитаются(t *test // Список координат упирается в потолок, счётчик — нет: обрезанный список // остаётся зацепкой для разбора, а масштаб события считает счётчик. -func TestMergePointsСчётчикРастётПослеПотолкаКоординат(t *testing.T) { +func TestMergeСчётчикРастётПослеПотолкаКоординат(t *testing.T) { t.Parallel() st := open(t) @@ -434,10 +434,10 @@ func TestMergePointsСчётчикРастётПослеПотолкаКоорд 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) } - stats, err := st.MergePoints(ctx, second, "d2") + stats, err := st.Merge(ctx, store.Incoming{Points: second}, store.DeliveryRef{ID: "d2"}) if err != nil { t.Fatalf("второе слияние: %v", err) } @@ -453,7 +453,7 @@ func TestMergePointsСчётчикРастётПослеПотолкаКоорд // source нестабилен: то же измерение приезжает то с одним именем устройства, // то с другим. Он не входит в ключ и не считается полнотой. -func TestMergePointsСменаИсточникаНеСоздаётВторуюТочку(t *testing.T) { +func TestMergeСменаИсточникаНеСоздаётВторуюТочку(t *testing.T) { t.Parallel() 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", `{"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) } - 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) } @@ -483,7 +483,7 @@ func TestMergePointsСменаИсточникаНеСоздаётВторуюТ // Исход столкновения точек равной полноты обязан зависеть только от значений: // свёртка по журналу должна давать то же состояние, что приём в реальном // времени, а внутри одной доставки время приёма общее. -func TestMergePointsРавнаяПолнотаРазрешаетсяДетерминированно(t *testing.T) { +func TestMergeРавнаяПолнотаРазрешаетсяДетерминированно(t *testing.T) { t.Parallel() ctx := context.Background() @@ -494,7 +494,7 @@ func TestMergePointsРавнаяПолнотаРазрешаетсяДетерм winner := func(order []store.IncomingPoint) string { st := open(t) 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) } } @@ -515,7 +515,7 @@ func TestMergePointsРавнаяПолнотаРазрешаетсяДетерм // Содержимое точки хранится исходными байтами: пересборка повторной // сериализацией теряет литерал, и потеря не видна тестам, сравнивающим // разобранное с разобранным. -func TestMergePointsХранитТочкуДословно(t *testing.T) { +func TestMergeХранитТочкуДословно(t *testing.T) { t.Parallel() st := open(t) @@ -525,7 +525,7 @@ func TestMergePointsХранитТочкуДословно(t *testing.T) { in := []store.IncomingPoint{ 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) } @@ -541,7 +541,7 @@ func TestMergePointsХранитТочкуДословно(t *testing.T) { // Тело доставки доходит до 42 МиБ, приходят они непрерывно и внахлёст. // Конкурентное слияние того же часа не имеет права терять точки: между // чтением и записью может вклиниться другая доставка. -func TestMergePointsКонкурентноеСлияниеНеТеряетТочки(t *testing.T) { +func TestMergeКонкурентноеСлияниеНеТеряетТочки(t *testing.T) { t.Parallel() st := open(t) @@ -568,7 +568,7 @@ func TestMergePointsКонкурентноеСлияниеНеТеряетТоч 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 return } @@ -607,7 +607,7 @@ func itoa(n int) string { // Изменение запечатанного часа — сигнал, а не отказ: данные пишутся всё равно, // но факт обязан дойти до владельца сервиса. Без счётчика допущение «глубже // такого-то порога досчёта не бывает» не получило бы ни одного наблюдения. -func TestMergePointsИзменениеЗапечатанногоЧаса(t *testing.T) { +func TestMergeИзменениеЗапечатанногоЧаса(t *testing.T) { t.Parallel() st := open(t) @@ -617,7 +617,7 @@ func TestMergePointsИзменениеЗапечатанногоЧаса(t *test first := []store.IncomingPoint{ 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) } if err := st.MarkSealed(ctx, "step_count", "minute", hour, true); err != nil { @@ -627,7 +627,7 @@ func TestMergePointsИзменениеЗапечатанногоЧаса(t *test late := []store.IncomingPoint{ 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 { t.Fatalf("досчёт запечатанного часа: %v", err) } @@ -649,7 +649,7 @@ func TestMergePointsИзменениеЗапечатанногоЧаса(t *test // Отмена посреди слияния не имеет права оставить половину: объект либо // прежний, либо полный. -func TestMergePointsОтменаНеОставляетПоловины(t *testing.T) { +func TestMergeОтменаНеОставляетПоловины(t *testing.T) { t.Parallel() st := open(t) @@ -657,19 +657,30 @@ func TestMergePointsОтменаНеОставляетПоловины(t *testin base := []store.IncomingPoint{ 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) } ctx, cancel := context.WithCancel(context.Background()) cancel() - more := []store.IncomingPoint{ - point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`), + // Сущности идут в ТОЙ ЖЕ транзакции и пишутся ПОСЛЕ объектов, то есть на + // той половине, где отмена вероятнее. Вынесение их во вторую транзакцию — + // напрашивающаяся правка при жалобе на длину транзакции с маршрутом, и она + // прошла бы зелёной, сломав «состояние пересобираемо»: доставка получила бы + // failed при записанной тренировке. + more := store.Incoming{ + Points: []store.IncomingPoint{ + 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("слияние на отменённом контексте прошло успешно") } + 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")) 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() st := open(t) @@ -714,7 +725,7 @@ func TestMergePointsПовторнаяСвёрткаНеМеняетСостоя state := func() (string, string) { 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) } 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() ctx := context.Background() @@ -752,7 +763,7 @@ func TestMergePointsИсходНеЗависитОтПерестановки(t * st := open(t) if split { 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) } } @@ -761,7 +772,7 @@ func TestMergePointsИсходНеЗависитОтПерестановки(t * for _, i := range order { 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) } } @@ -785,7 +796,7 @@ func TestMergePointsИсходНеЗависитОтПерестановки(t * // Точка, ни одно значение которой не несёт измерения, не должна вытеснять // настоящее измерение. Раньше вытесняла: множества содержательных ключей // равны, и решал второй разряд — по ключам, а не по содержанию. -func TestMergePointsПадингНеВытесняетИзмерение(t *testing.T) { +func TestMergeПадингНеВытесняетИзмерение(t *testing.T) { t.Parallel() 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", 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 { t.Fatalf("слияние: %v", err) } @@ -824,7 +835,7 @@ func TestMergePointsПадингНеВытесняетИзмерение(t *test // Имя метрики приходит из тела доставки дословно и ничем не ограничено. // Без обрезки одна доставка порождает WARN-строку в десятки мегабайт и // вытесняет из ротации логов всю недавнюю историю. -func TestMergePointsИмяМетрикиВКоординатеОбрезано(t *testing.T) { +func TestMergeИмяМетрикиВКоординатеОбрезано(t *testing.T) { t.Parallel() 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":2}`), } - stats, err := st.MergePoints(ctx, in, "d1") + stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}) if err != nil { t.Fatalf("слияние: %v", err) } diff --git a/internal/store/entity.go b/internal/store/entity.go new file mode 100644 index 0000000..c4c0373 --- /dev/null +++ b/internal/store/entity.go @@ -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 +} diff --git a/internal/store/entity_test.go b/internal/store/entity_test.go new file mode 100644 index 0000000..4d0bb29 --- /dev/null +++ b/internal/store/entity_test.go @@ -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":"ad","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("два разных состояния витрины дали один отпечаток: составной ключ склеен до взятия длины") + } +} diff --git a/internal/store/migration_test.go b/internal/store/migration_test.go index b7b687f..59772fc 100644 --- a/internal/store/migration_test.go +++ b/internal/store/migration_test.go @@ -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) + } + } +} diff --git a/internal/store/migrations/00007_workout_record.sql b/internal/store/migrations/00007_workout_record.sql new file mode 100644 index 0000000..21525aa --- /dev/null +++ b/internal/store/migrations/00007_workout_record.sql @@ -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; diff --git a/internal/store/regress_test.go b/internal/store/regress_test.go index c0af9b3..344aedd 100644 --- a/internal/store/regress_test.go +++ b/internal/store/regress_test.go @@ -17,7 +17,7 @@ import ( // каждом глубоком проходе, и настоящий отказ правила слияния становился // неотличим от нормы — при том что счётчик перезаписей объявлен единственным // наблюдением за этим правилом. -func TestMergePointsДребезгНеСчитаетсяСтолкновением(t *testing.T) { +func TestMergeДребезгНеСчитаетсяСтолкновением(t *testing.T) { t.Parallel() 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]) 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) } - 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 { t.Fatalf("второе слияние: %v", err) } @@ -63,7 +63,7 @@ func TestMergePointsДребезгНеСчитаетсяСтолкновение // Настоящее столкновение обязано оставить след с координатами объекта: одно // число `overwrites` не говорит, какая метрика и какой час пострадали, и // расследовать перезапись по нему нечем. -func TestMergePointsСтолкновениеОставляетКоординаты(t *testing.T) { +func TestMergeСтолкновениеОставляетКоординаты(t *testing.T) { t.Parallel() 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", `{"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) } - 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 { t.Fatalf("второе слияние: %v", err) } @@ -98,7 +98,7 @@ func TestMergePointsСтолкновениеОставляетКоординат // экранирует `&`, `<` и `>` внутри содержимого — порчи значений это не даёт, но // обещание перестаёт быть правдой, а сравнение байтов при следующей доставке // той же точки начинает промахиваться навсегда. -func TestMergePointsХранитУгловыеСкобкиИАмперсанд(t *testing.T) { +func TestMergeХранитУгловыеСкобкиИАмперсанд(t *testing.T) { t.Parallel() st := open(t) @@ -107,7 +107,7 @@ func TestMergePointsХранитУгловыеСкобкиИАмперсанд(t raw := `{"qty":1,"source":"Anton & Co \"x\""}` 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) } @@ -129,7 +129,7 @@ func TestMergePointsХранитУгловыеСкобкиИАмперсанд(t // Смена единиц не имеет права молча переподписать уже сохранённые точки: // внутри точки единиц нет, и у ранних точек не остаётся ничего, по чему их // единицы восстановимы. Правило «первое непустое побеждает» плюс счётчик. -func TestMergePointsСменаЕдиницНеПерезаписываетМолча(t *testing.T) { +func TestMergeСменаЕдиницНеПерезаписываетМолча(t *testing.T) { t.Parallel() st := open(t) @@ -148,10 +148,10 @@ func TestMergePointsСменаЕдиницНеПерезаписываетМол mi.End = mi.Start 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) } - 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 { t.Fatalf("второе слияние: %v", err) } @@ -173,14 +173,14 @@ func TestMergePointsСменаЕдиницНеПерезаписываетМол // доставки схлопываются, и счётчик присланных систематически завышал бы // содержимое витрины — расхождение «прислали 1000, лежит 700» было бы невидимо // ровно тогда, когда точки начнут теряться по-настоящему. -func TestMergePointsСчитаетСохранённые(t *testing.T) { +func TestMergeСчитаетСохранённые(t *testing.T) { t.Parallel() st := open(t) ctx := context.Background() 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 { t.Fatalf("слияние: %v", err) } @@ -188,7 +188,7 @@ func TestMergePointsСчитаетСохранённые(t *testing.T) { 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 { 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() st := open(t) @@ -218,7 +218,7 @@ func TestMergePointsОтказНеОставляетЧастиОбъектов(t ctx, cancel := context.WithCancel(context.Background()) 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("слияние на отменённом контексте прошло успешно") } diff --git a/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/.openspec.yaml b/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/.openspec.yaml new file mode 100644 index 0000000..d658936 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-02 diff --git a/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/design.md b/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/design.md new file mode 100644 index 0000000..68061cc --- /dev/null +++ b/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/design.md @@ -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 строкам. diff --git a/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/proposal.md b/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/proposal.md new file mode 100644 index 0000000..2fbe62b --- /dev/null +++ b/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/proposal.md @@ -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`: + тренировка с маршрутом и состояние разума — на реальных пакетах с вычищенными + измерениями. diff --git a/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/specs/parsing/spec.md b/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/specs/parsing/spec.md new file mode 100644 index 0000000..24fd07d --- /dev/null +++ b/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/specs/parsing/spec.md @@ -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** содержимое непокрытой секции в результат разбора не попадает diff --git a/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/specs/reindex/spec.md b/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/specs/reindex/spec.md new file mode 100644 index 0000000..1dc50a8 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/specs/reindex/spec.md @@ -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** он не содержит ни значений точек, ни имён метрик, ни имён устройств diff --git a/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/specs/storage/spec.md b/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/specs/storage/spec.md new file mode 100644 index 0000000..284cfd3 --- /dev/null +++ b/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/specs/storage/spec.md @@ -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** содержит число отброшенных имён diff --git a/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/tasks.md b/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/tasks.md new file mode 100644 index 0000000..c67d6be --- /dev/null +++ b/openspec/changes/archive/2026-08-02-trenirovki-i-zapisi/tasks.md @@ -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 У каждого класса пропуска при разборе сущности — свой счётчик, и + пропуск одного элемента не уносит соседей diff --git a/openspec/specs/parsing/spec.md b/openspec/specs/parsing/spec.md index aacf42f..728b601 100644 --- a/openspec/specs/parsing/spec.md +++ b/openspec/specs/parsing/spec.md @@ -159,9 +159,29 @@ Read API «самый мелкий слой, покрывающий диапаз ### Requirement: Разбор форматов времени -Система SHALL разбирать метку точки формата `2026-07-31 21:03:51 +0300` и -приводить её к UTC, сохраняя офсет исходной зоны. В секции `data.metrics` -других форматов меток не встречается. +Система 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 преобразовывать @@ -169,16 +189,24 @@ Unix-эпоха дробным числом (`1785446196.4132624`) встреч и обратно не гарантирует дословности, а серия составляет 93% объёма метрики `heart_rate_variability`. -RFC 3339 (`2026-07-31T18:03:51Z`) в этой дельте не нормируется: он встречается -только в `data.stateOfMind`, которая выведена из scope. Требование к нему -появится вместе с задачей про секции с собственными `id` — вместе с данными, -на которых его можно проверить. +Время внутри маршрута тренировки (`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` @@ -186,6 +214,12 @@ RFC 3339 (`2026-07-31T18:03:51Z`) в этой дельте не нормируе - **AND** серия не разворачивается в отдельные точки - **AND** эпоха внутри серии не разбирается и не преобразуется +#### Scenario: Время внутри маршрута не разбирается + +- **WHEN** тренировка содержит `route` с полем `timestamp` у каждой точки +- **THEN** точки маршрута сохраняются исходными байтами +- **AND** их метки не разбираются и не преобразуются + ### Requirement: Разделение схем под одним именем метрики Система SHALL разводить на разные имена метрики те схемы, которые Health Auto @@ -270,32 +304,38 @@ Export шлёт под одним именем, чтобы одно имя оз MUST NOT удерживаться после того, как разбор прошёл мимо неё: тела доходят до 42 МиБ, и удержание кучи здесь — часть контракта, а не деталь реализации. -Покрытым сегодня является ровно один ключ — `metrics`. Разбор и перечисление -MUST ходить по одному объявленному множеству покрытых имён: состояние «секция -разбирается, но числится непокрытой» невыразимо по построению. +Покрытых ключей сегодня три — `metrics`, `workouts` и `stateOfMind`. Разбор и +перечисление MUST ходить по одному объявленному множеству покрытых имён: +состояние «секция разбирается, но числится непокрытой» невыразимо по построению. Непокрытым ключ считается независимо от того, что лежит внутри: содержимое не интерпретируется, поэтому и о пустоте секции разбор честно ничего не знает. -Измерено на живом архиве — пустых секций HAE не присылает ни разу (99 доставок). +Измерено на живом архиве — пустых секций HAE не присылает ни разу (118 доставок). Список SHALL быть каноничен: имена отсортированы, повторов нет. Порядок ключей в JSON от HAE нестабилен, а значение уезжает в базу и сравнивается между доставками. Отсутствие непокрытых ключей и отсутствие секции `metrics` — разные события, и -оба нормальны: половина потока состоит из доставок без метрик вовсе (48 из 99). +оба нормальны: половина потока состоит из доставок без метрик вовсе (53 из 118). #### Scenario: Незнакомая секция попадает в список непокрытых -- **WHEN** тело содержит `data.workouts` наряду с `data.metrics` -- **THEN** разбор возвращает `workouts` в списке непокрытых ключей +- **WHEN** тело содержит `data.ecg` наряду с `data.metrics` +- **THEN** разбор возвращает `ecg` в списке непокрытых ключей - **AND** точки секции `metrics` разбираются как обычно -#### Scenario: Доставка без метрик разбирается и не теряется +#### Scenario: Доставка из одних тренировок непокрытых ключей не даёт + +- **WHEN** тело содержит только `data.workouts` +- **THEN** разбор завершается без ошибки, точек нет, тренировки разобраны +- **AND** список непокрытых ключей пуст + +#### Scenario: Доставка из одного состояния разума непокрытых ключей не даёт - **WHEN** тело содержит только `data.stateOfMind` -- **THEN** разбор завершается без ошибки, точек нет -- **AND** `stateOfMind` возвращается в списке непокрытых ключей +- **THEN** разбор завершается без ошибки, записи разобраны +- **AND** список непокрытых ключей пуст #### Scenario: Доставка из одних метрик непокрытых ключей не даёт @@ -345,15 +385,26 @@ JSON от HAE нестабилен, а значение уезжает в баз ### Requirement: Отказ разбора остаётся всё или ничего Разбор SHALL оставаться операцией «всё или ничего»: ошибка, встреченная -**после** того, как секция `metrics` уже разобрана (обрезанное тело, мусор в -следующем члене), MUST NOT оставлять точки в результате — доставка считается -неразобранной целиком. +**после** того, как покрытая секция уже разобрана (обрезанное тело, мусор в +следующем члене), MUST NOT оставлять в результате ни точек, ни сущностей — +доставка считается неразобранной целиком. -Иначе часть точек оказалась бы в витрине под статусом, по которому доставку +Иначе часть данных оказалась бы в витрине под статусом, по которому доставку никто не подберёт, и свёртка перестала бы быть детерминированной по журналу. -Повтор ключа `metrics` в одном объекте `data` SHALL давать объединение секций, а -не победу последней: молча терять точки нельзя. +Правило SHALL распространяться и на невыводимый слой: доставка, у которой есть +метрики, но слой их не определяется, не сохраняет и своих сущностей, хотя слоя +у сущности нет. Соблазн «сущности от слоя не зависят, запишем их» ломает то же +«всё или ничего» — доставка получила бы `failed` при частично записанной +витрине, и повторная свёртка перестала бы быть no-op. Цена названа вслух: если +такая доставка когда-нибудь принесёт `stateOfMind`, его записи доедут не сразу, +а пересборкой; тело при этом остаётся в архиве, и `failed` ретеншену трогать +нельзя. + +Повтор ключа покрытой секции в одном объекте `data` SHALL давать объединение +секций, а не победу последней: молча терять данные нельзя. То же SHALL +относиться к повтору самого члена `data` в теле — результаты **накапливаются**, +включая список непокрытых ключей. #### Scenario: Тело оборвано после секции метрик @@ -361,8 +412,173 @@ JSON от HAE нестабилен, а значение уезжает в баз оборван - **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 разбирать секции тела, элементы которых несут собственный `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** сущностей из неё разбор не отдаёт + diff --git a/openspec/specs/reindex/spec.md b/openspec/specs/reindex/spec.md index 97d8fec..4d207f3 100644 --- a/openspec/specs/reindex/spec.md +++ b/openspec/specs/reindex/spec.md @@ -309,6 +309,12 @@ отпечатка** — рабочей витрины и пересобранной, — с прямым ответом, совпали они или нет. +Счётчики «до и после» SHALL покрывать **каждую единицу хранения витрины**: +часовые объекты, тренировки и записи. Отпечаток отвечает «да/нет» за витрину +целиком, поэтому единица, которой нет в счётчиках, делает расхождение +безадресным: человек увидит «не совпало» при неизменившемся числе объектов и не +отличит появление двадцати семи тренировок от пропажи двух. + Отказы SHALL считаться **по классам**: слой не выводится, содержимое не разбирается, работа отложена по обстоятельствам, всё прочее. Невыведенный слой есть в каждом журнале и штатен; общий счётчик отправлял бы человека искать @@ -342,8 +348,14 @@ пересобранной витрины и число доставок после прогона не измеряются вовсе — печатать их сравнение значило бы выдать неизмеренное за измеренное, причём в единственном оракуле задачи. Ожидаемые классы расхождения (новые доставки за время прогона, -непереносимый признак запечатанного часа, исправленный разбор) SHALL называться -отдельно от самого факта расхождения. +непереносимый признак запечатанного часа, исправленный разбор, **покрытая +разбором новая секция**) SHALL называться отдельно от самого факта расхождения. + +Класс «покрыта новая секция» назван потому, что первый прогон после такого +изменения расходится **гарантированно** и штатно: витрина обзаводится единицами +хранения, которых в рабочей базе нет по построению. Не назвав его, отчёт +приучает человека игнорировать расхождение отпечатков — то есть обесценивает +оракул ровно тогда, когда по нему принимается необратимое решение. **Исход команды.** Расхождение отпечатков отказом быть MUST NOT: после исправления разбора оно ожидаемо и есть сам смысл пересборки. Отказ отдельной @@ -373,6 +385,12 @@ SHALL идти в поток ошибок, а не смешиваться с о - **AND** прямо называет, совпали они или нет - **AND** называет, изменилось ли число доставок в рабочей базе за время прогона +#### Scenario: Счётчики покрывают все единицы хранения + +- **WHEN** пересборка завершилась +- **THEN** отчёт печатает «до и после» отдельно для часовых объектов, + тренировок и записей + #### Scenario: Расхождение отпечатков не является отказом - **WHEN** отпечаток пересобранной витрины отличается от рабочей, и при этом diff --git a/openspec/specs/storage/spec.md b/openspec/specs/storage/spec.md index 1e59a9d..79bfeca 100644 --- a/openspec/specs/storage/spec.md +++ b/openspec/specs/storage/spec.md @@ -301,26 +301,39 @@ HTML-экранирования: `&`, `<` и `>` внутри точки обя ### Requirement: Значения точек не попадают в логи Данные о здоровье чувствительнее токенов. Система MUST NOT писать значения -точек и тела доставок в записи лога уровня выше `DEBUG`. +точек, содержимое сущностей и тела доставок в записи лога уровня выше `DEBUG`. + +Содержимое сущности здесь не менее чувствительно, чем значение точки, а местами +более: маршрут тренировки — это геотрек до дома, а `labels` и `associations` +записи состояния разума — эмоциональные метки. Разрешены **координаты**: +идентификатор и род сущности, метка времени, идентификатор доставки — они +описывают, что случилось, а не что измерено. Непокрытые секции называются в логе **именами ключей**: имя секции — это форма пакета, а не измерение. Содержимое секции в лог не попадает ни при каком уровне выше `DEBUG`. Имена идут структурным атрибутом, а не склейкой в текст сообщения: кодировщик экранирует управляющие символы, и имя из чужого тела не разрывает -построчный разбор логов. +построчный разбор логов. То же относится к идентификатору сущности: он приходит +из чужого тела и ограничен по длине при разборе. Частичный разбор уровня записи не повышает: `partial` — установившееся состояние -половины потока (48 доставок из 99), и постоянный `WARN` обесценил бы уровень. +половины потока (53 доставки из 118), и постоянный `WARN` обесценил бы уровень. Повышает уровень другое — срабатывание границ списка: тело с сотнями секций или с именем длиннее предела на HAE не похоже вовсе. #### Scenario: Разбор доставки логируется без значений - **WHEN** доставка разобрана -- **THEN** запись лога содержит счётчики (метрик, точек, объектов) и +- **THEN** запись лога содержит счётчики (метрик, точек, объектов, сущностей) и идентификатор доставки - **AND** не содержит ни значений точек, ни имён устройств +#### Scenario: Удержанная обеднённая версия логируется координатами + +- **WHEN** приехавшая версия сущности отклонена как теряющая содержание +- **THEN** запись `WARN` содержит идентификатор и род сущности +- **AND** не содержит ни точек маршрута, ни того, какие поля потерялись + #### Scenario: Непокрытые секции названы именами ключей - **WHEN** доставка содержит непокрытую секцию @@ -427,3 +440,271 @@ Apple его нет (находка 46). Поэтому список MUST сох - **THEN** `parse_status` становится `parsed` - **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** отпечаток отражает одно состояние базы, а не смесь снимков +