feat: разбор и хранение тренировок и состояния разума
- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`; миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки с этими ключами - сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА — «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина расходилась бы с пересборкой молча - отпечаток витрины покрывает тренировки и записи и снимается одним снимком базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
This commit is contained in:
@@ -174,9 +174,14 @@ type report struct {
|
|||||||
dbPath string
|
dbPath string
|
||||||
sourcePrint string
|
sourcePrint string
|
||||||
sourceBuckets int64
|
sourceBuckets int64
|
||||||
sourceBefore int64
|
// sourceWorkouts и sourceRecords — то же «было» для остальных единиц
|
||||||
sourceAfter int64
|
// хранения витрины. Отпечаток отвечает «да/нет» за витрину целиком, поэтому
|
||||||
sourceMissing bool
|
// единица, которой нет в счётчиках, делает расхождение безадресным.
|
||||||
|
sourceWorkouts int64
|
||||||
|
sourceRecords int64
|
||||||
|
sourceBefore int64
|
||||||
|
sourceAfter int64
|
||||||
|
sourceMissing bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// rebuild собирает витрину в промежуточный файл и переименовывает его в файл
|
// 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 {
|
if rep.sourceBuckets, err = src.CountBuckets(ctx); err != nil {
|
||||||
return canceledOr(rep, err, stopped)
|
return canceledOr(rep, err, stopped)
|
||||||
}
|
}
|
||||||
|
if rep.sourceWorkouts, err = src.CountWorkouts(ctx); err != nil {
|
||||||
|
return canceledOr(rep, err, stopped)
|
||||||
|
}
|
||||||
|
if rep.sourceRecords, err = src.CountRecords(ctx); err != nil {
|
||||||
|
return canceledOr(rep, err, stopped)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
removeDB(t.partial)
|
removeDB(t.partial)
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ func writeReport(w io.Writer, r report) {
|
|||||||
|
|
||||||
if r.sourceMissing {
|
if r.sourceMissing {
|
||||||
p(" объектов: %d", r.replay.Buckets)
|
p(" объектов: %d", r.replay.Buckets)
|
||||||
|
p(" тренировок: %d", r.replay.Workouts)
|
||||||
|
p(" записей: %d", r.replay.Records)
|
||||||
p("")
|
p("")
|
||||||
p("рабочей базы %s нет — сверять не с чем, а заголовки доставок", r.dbPath)
|
p("рабочей базы %s нет — сверять не с чем, а заголовки доставок", r.dbPath)
|
||||||
p("не восстанавливаются: в архиве их нет.")
|
p("не восстанавливаются: в архиве их нет.")
|
||||||
@@ -66,6 +68,8 @@ func writeReport(w io.Writer, r report) {
|
|||||||
// расхождения. Отпечатки отвечают «да/нет», а решение о подмене
|
// расхождения. Отпечатки отвечают «да/нет», а решение о подмене
|
||||||
// необратимо; именно пара чисел 1737/1742 поймала прошлый дефект.
|
// необратимо; именно пара чисел 1737/1742 поймала прошлый дефект.
|
||||||
p(" объектов: было %d, стало %d", r.sourceBuckets, r.replay.Buckets)
|
p(" объектов: было %d, стало %d", r.sourceBuckets, r.replay.Buckets)
|
||||||
|
p(" тренировок: было %d, стало %d", r.sourceWorkouts, r.replay.Workouts)
|
||||||
|
p(" записей: было %d, стало %d", r.sourceRecords, r.replay.Records)
|
||||||
p("")
|
p("")
|
||||||
p(" отпечаток рабочей: %s", r.sourcePrint)
|
p(" отпечаток рабочей: %s", r.sourcePrint)
|
||||||
p(" отпечаток пересобранной: %s", r.replay.Fingerprint)
|
p(" отпечаток пересобранной: %s", r.replay.Fingerprint)
|
||||||
@@ -76,8 +80,9 @@ func writeReport(w io.Writer, r report) {
|
|||||||
p(" отпечатки совпали, но сверка НЕПОЛНА: часть журнала не прочитана")
|
p(" отпечатки совпали, но сверка НЕПОЛНА: часть журнала не прочитана")
|
||||||
default:
|
default:
|
||||||
p(" отпечатки РАЗОШЛИСЬ")
|
p(" отпечатки РАЗОШЛИСЬ")
|
||||||
p(" ожидаемые причины: исправленный разбор; признак sealed не")
|
p(" ожидаемые причины: исправленный разбор; покрытая разбором новая")
|
||||||
p(" переносится (правила его выставления ещё нет)")
|
p(" секция (её единиц хранения в рабочей базе нет по построению);")
|
||||||
|
p(" признак sealed не переносится (правила его выставления ещё нет)")
|
||||||
if partialJournal {
|
if partialJournal {
|
||||||
p(" ВНИМАНИЕ: часть журнала не прочитана — расхождение может")
|
p(" ВНИМАНИЕ: часть журнала не прочитана — расхождение может")
|
||||||
p(" объясняться этим, а не разбором")
|
p(" объясняться этим, а не разбором")
|
||||||
|
|||||||
@@ -88,14 +88,16 @@ func TestОтчётНеРаскрываетДанныхОЗдоровье(t *tes
|
|||||||
replay: replay.Report{
|
replay: replay.Report{
|
||||||
Bodies: 116,
|
Bodies: 116,
|
||||||
Outcome: replay.Outcome{Folded: 116, Partial: 53},
|
Outcome: replay.Outcome{Folded: 116, Partial: 53},
|
||||||
Buckets: 2049, Fingerprint: "aaaa",
|
Buckets: 2049, Workouts: 2, Records: 2, Fingerprint: "aaaa",
|
||||||
},
|
},
|
||||||
target: "/data/healthlog.db.rebuild",
|
target: "/data/healthlog.db.rebuild",
|
||||||
dbPath: "/data/healthlog.db",
|
dbPath: "/data/healthlog.db",
|
||||||
sourcePrint: "bbbb",
|
sourcePrint: "bbbb",
|
||||||
sourceBuckets: 2040,
|
sourceBuckets: 2040,
|
||||||
sourceBefore: 116,
|
sourceWorkouts: 0,
|
||||||
sourceAfter: 116,
|
sourceRecords: 0,
|
||||||
|
sourceBefore: 116,
|
||||||
|
sourceAfter: 116,
|
||||||
})
|
})
|
||||||
out := buf.String()
|
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) {
|
if !strings.Contains(out, want) {
|
||||||
t.Errorf("отчёт не содержит %q", want)
|
t.Errorf("отчёт не содержит %q", want)
|
||||||
}
|
}
|
||||||
|
|||||||
+117
-14
@@ -317,9 +317,11 @@ HRV); у накопительных — только `date`. Поэтому то
|
|||||||
|
|
||||||
#### Частичный разбор
|
#### Частичный разбор
|
||||||
|
|
||||||
Разбор покрывает секцию `metrics`; `workouts`, `stateOfMind`, `symptoms`, `ecg`
|
Разбор покрывает `metrics`, `workouts` и `stateOfMind`; `symptoms`, `ecg`,
|
||||||
и прочие проходят мимо. Это половина потока: 48 доставок из 99 не несут
|
`cycleTracking`, `medications` и `heartRateNotifications` проходят мимо. Живой
|
||||||
`metrics` вовсе (находка 50).
|
поток последних не приносил ни разу (118 доставок: 65 с метриками, 27 с
|
||||||
|
тренировками, 26 с состоянием разума), так что сегодня непокрытая секция —
|
||||||
|
редкость, а не половина потока, как было до покрытия сущностей.
|
||||||
|
|
||||||
Такая доставка получает статус `partial`, а имена непокрытых секций — колонку
|
Такая доставка получает статус `partial`, а имена непокрытых секций — колонку
|
||||||
`delivery.uncovered_sections`. Статус отвечает на вопрос «разобрано ли всё»,
|
`delivery.uncovered_sections`. Статус отвечает на вопрос «разобрано ли всё»,
|
||||||
@@ -343,7 +345,15 @@ HRV); у накопительных — только `date`. Поэтому то
|
|||||||
покрытия на момент свёртки; доставки, свёрнутые до того, как секция стала
|
покрытия на момент свёртки; доставки, свёрнутые до того, как секция стала
|
||||||
покрытой, останутся `partial` со старым списком, и ретеншен будет вечно щадить
|
покрытой, останутся `partial` со старым списком, и ретеншен будет вечно щадить
|
||||||
ненужные тела. Задача, которая начинает разбирать секцию, тем же изменением
|
ненужные тела. Задача, которая начинает разбирать секцию, тем же изменением
|
||||||
переводит `partial`-строки с этим ключом в `pending`.
|
переводит `partial`-строки с этим ключом в `pending`. Так сделала миграция
|
||||||
|
`00007`, покрывшая `workouts` и `stateOfMind`.
|
||||||
|
|
||||||
|
**Следствие для ретеншена, названное вслух.** Пока `stateOfMind` был непокрыт,
|
||||||
|
его тела защищал сам статус `partial`. Теперь такая доставка получает `parsed` и
|
||||||
|
неотличима от доставки из метрик — а метрики восстановимы из экспорта Apple,
|
||||||
|
состояние разума нет (находка 46). Ретеншена в проекте нет, поэтому сегодня не
|
||||||
|
ломается ничего; но предусловие, которое задача ретеншена считала снятым, снова
|
||||||
|
открыто, и признак невосстановимости придётся завести отдельно от «непокрытости».
|
||||||
|
|
||||||
- **413** — тело больше допустимого. Граница стоит на **распакованном**
|
- **413** — тело больше допустимого. Граница стоит на **распакованном**
|
||||||
потоке, а не только на сжатом: `MaxBytesReader` поверх `r.Body` ограничивает
|
потоке, а не только на сжатом: `MaxBytesReader` поверх `r.Body` ограничивает
|
||||||
@@ -551,12 +561,14 @@ bucket(metric, layer, hour_utc, units, payload BLOB, content_hash, points,
|
|||||||
first_ts, last_ts, first_delivery_id, sealed, created_at, updated_at)
|
first_ts, last_ts, first_delivery_id, sealed, created_at, updated_at)
|
||||||
PK (metric, layer, hour_utc) WITHOUT ROWID
|
PK (metric, layer, hour_utc) WITHOUT ROWID
|
||||||
|
|
||||||
workout(id PK, name, start_utc, end_utc, tz_offset, duration_sec,
|
workout(id PK, name, start_utc, end_utc, tz_offset, duration_sec REAL NULL,
|
||||||
payload JSON, delivery_id, updated_at)
|
payload BLOB, content_hash, delivery_id, delivery_received_at,
|
||||||
|
created_at, updated_at)
|
||||||
|
INDEX (start_utc)
|
||||||
|
|
||||||
record(id PK, kind, ts_utc, tz_offset, payload JSON,
|
record(kind, id, ts_utc, tz_offset, payload BLOB, content_hash,
|
||||||
delivery_id, updated_at)
|
delivery_id, delivery_received_at, created_at, updated_at)
|
||||||
INDEX (kind, ts_utc)
|
PK (kind, id) INDEX (kind, ts_utc)
|
||||||
```
|
```
|
||||||
|
|
||||||
Зачем пачками:
|
Зачем пачками:
|
||||||
@@ -814,10 +826,17 @@ value_code "HKCategoryValueSleepAnalysisAsleepREM" ← выведено по с
|
|||||||
|
|
||||||
### Тренировки и прочие секции
|
### Тренировки и прочие секции
|
||||||
|
|
||||||
Тренировка адресуется своим `id` из HealthKit и **перезаписывается**: она
|
Тренировка адресуется своим `id` из HealthKit, запись — парой `род + id`.
|
||||||
может приехать повторно, когда доедет маршрут. `record` держит секции с
|
`record` держит секции с собственными идентификаторами; разбором покрыт пока
|
||||||
собственными идентификаторами (`stateOfMind`, `ecg`, `symptoms`,
|
только `stateOfMind`, а `ecg`, `symptoms`, `cycleTracking`, `medications` и
|
||||||
`cycleTracking`, `medications`, `heartRateNotifications`) — модель та же.
|
`heartRateNotifications` остаются непокрытыми **намеренно**: живой поток не
|
||||||
|
приносил их ни разу, их формы никто не видел, а полнота покрытия HealthKit ради
|
||||||
|
полноты целью проекта не является. Модель под них заложена — новая секция
|
||||||
|
добавляется одной строкой в множество покрытых имён, а не миграцией.
|
||||||
|
|
||||||
|
Ключ записи — **пара**, а не один `id`: собственный `id` наблюдался живьём
|
||||||
|
только у `stateOfMind`, где он UUID, и короткий несквозной идентификатор в двух
|
||||||
|
разных секциях затёр бы одну запись другой молча.
|
||||||
|
|
||||||
Пачками они не хранятся: у них есть естественный ключ, они редки, и
|
Пачками они не хранятся: у них есть естественный ключ, они редки, и
|
||||||
группировать их по часам незачем.
|
группировать их по часам незачем.
|
||||||
@@ -825,12 +844,96 @@ value_code "HKCategoryValueSleepAnalysisAsleepREM" ← выведено по с
|
|||||||
**Тренировка не разворачивается.** Заголовок — колонками, всё остальное,
|
**Тренировка не разворачивается.** Заголовок — колонками, всё остальное,
|
||||||
включая маршрут и внутренние ряды, — блобом `payload`. Структура тренировки
|
включая маршрут и внутренние ряды, — блобом `payload`. Структура тренировки
|
||||||
разнородна и избыточна (сводки дублируют ряды, находка 15); раскладывать её в
|
разнородна и избыточна (сводки дублируют ряды, находка 15); раскладывать её в
|
||||||
таблицы значило бы решить за Apple, что в ней главное.
|
таблицы значило бы решить за Apple, что в ней главное. Колонок ровно столько,
|
||||||
|
сколько нужно выборке: имя, интервал, офсет зоны, длительность. Длительность
|
||||||
|
берётся из тела, а не считается как `end - start` (HAE шлёт 91.746 при
|
||||||
|
интервале в 91 секунду), и её отсутствие выражается пустотой, а не нулём — ноль
|
||||||
|
законная длительность.
|
||||||
|
|
||||||
**Пульс приедет дважды** — в общем потоке метрики `heart_rate` и внутри
|
**Пульс приедет дважды** — в общем потоке метрики `heart_rate` и внутри
|
||||||
объекта тренировки. Это ожидаемо, они лежат в разных таблицах и не
|
объекта тренировки. Это ожидаемо, они лежат в разных таблицах и не
|
||||||
смешиваются.
|
смешиваются.
|
||||||
|
|
||||||
|
#### Замена версии сущности
|
||||||
|
|
||||||
|
«Перезаписывается» уточнено измерением. Тренировка приезжает повторно каждой
|
||||||
|
доставкой, пока источник её досчитывает: на живом архиве одна тренировка
|
||||||
|
приехала 26 раз в трёх различных содержимых — сперва добавились `stepCadence` и
|
||||||
|
`stepCount` вместе с изменившимся рядом `activeEnergy`, затем при том же наборе
|
||||||
|
полей досчитались `totalEnergy` и `basalEnergy`. То есть тренировка правится
|
||||||
|
задним числом ровно так же, как минутное ведро (находка 10), а набор её полей
|
||||||
|
за весь корпус ни разу не уменьшился.
|
||||||
|
|
||||||
|
Правило:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. каноническая форма совпала с сохранённой → записи нет (хеш-детектор)
|
||||||
|
2. приехавшая несёт всё содержание сохранённой
|
||||||
|
и сверх того → приехавшая замещает целиком
|
||||||
|
3. приехавшая теряет содержание сохранённой → остаётся сохранённая,
|
||||||
|
счётчик + WARN
|
||||||
|
4. содержание равно → версия из более поздней
|
||||||
|
доставки ЖУРНАЛА
|
||||||
|
5. наборы несравнимы → остаётся сохранённая,
|
||||||
|
счётчик + WARN
|
||||||
|
```
|
||||||
|
|
||||||
|
**Содержание сравнивается множеством ключей с непустым значением и длиной
|
||||||
|
верхнеуровневых массивов — но не значениями.** Правило полноты, принятое для
|
||||||
|
точек, здесь неприменимо, и это проверено выполненной командой: оно гасит
|
||||||
|
отношение включения до «равенства», когда значения общих ключей разошлись, — а
|
||||||
|
у сущности они расходятся всегда. Обеднённая версия получила бы «равенство» и
|
||||||
|
заместила бы сохранённую вместе с маршрутом, причём тест на фикстуре с
|
||||||
|
неизменёнными значениями остался бы зелёным. Длина массивов добавлена потому,
|
||||||
|
что усечённый маршрут (три точки вместо 593) ключа не теряет, а теряет 95% веса
|
||||||
|
тренировки. Предел правила назван вслух: сокращение **внутри** элемента ряда не
|
||||||
|
ловится ничем, кроме сверки с телом в архиве.
|
||||||
|
|
||||||
|
**Тай-брейк при равном содержании — позиция доставки в журнале
|
||||||
|
`(received_at, id)`, а не порядок свёртки.** Напрашивавшееся «побеждает
|
||||||
|
приехавшая» отвергнуто: приехавшая есть функция порядка свёртки, а он порядку
|
||||||
|
журнала не равен (см. «Предел порядка назван вслух»). Доставка с более ранней
|
||||||
|
меткой, свёрнутая позже, вернула бы витрину к недосчитанной версии, и
|
||||||
|
пересборка разошлась бы с живым приёмом **молча** — в содержимом тренировки, где
|
||||||
|
это не видно ничем, кроме отпечатка. Поэтому сущность несёт провенанс:
|
||||||
|
доставку своей версии и её метку приёма. Тай-брейк по канонической форме (как у
|
||||||
|
точек) отвергнут по другой причине: он заморозил бы тренировку на произвольной
|
||||||
|
из версий навсегда, вместе с недосчитанной энергией.
|
||||||
|
|
||||||
|
Две версии одного ключа **внутри одной доставки** позициями не различаются и
|
||||||
|
разрешаются минимумом канонической формы: порядок элементов в JSON-массиве
|
||||||
|
нестабилен.
|
||||||
|
|
||||||
|
Отвергнут и **голый upsert по `id`** (так делает сервер HealthyApps поверх
|
||||||
|
MongoDB, и так просилось из слова «перезаписывается»): единственный наблюдённый
|
||||||
|
сценарий повторной присылки — рост, но маршрут стоит 95% содержимого, а
|
||||||
|
восстановление требует пересборки всего журнала. Условие пункта 3 стоит одного
|
||||||
|
сравнения множеств и делает событие наблюдаемым вместо необратимого.
|
||||||
|
|
||||||
|
Остаточный предел назван вслух: слияние попарное, поэтому при несравнимых
|
||||||
|
наборах (пункт 5) исход зависит от порядка проигрывания. Тот же предел есть у
|
||||||
|
часового объекта — в нём лежит победитель прошлых слияний, а не все кандидаты
|
||||||
|
истории.
|
||||||
|
|
||||||
|
#### Отпечаток и отчёт пересборки идут за витриной
|
||||||
|
|
||||||
|
Отпечаток покрывает **все** единицы хранения и снимается одной транзакцией
|
||||||
|
чтения: отпечаток одних часовых объектов давал бы «состояние сошлось» при
|
||||||
|
разъехавшихся тренировках, а три запроса вне общей транзакции под живым приёмом
|
||||||
|
дали бы смесь снимков и ложное «разошлись». Отчёт `reindex` считает «было и
|
||||||
|
стало» по каждой единице и называет «покрыта новая секция» ожидаемым классом
|
||||||
|
расхождения — иначе первый прогон после такого изменения расходится
|
||||||
|
гарантированно, а человек читает это как дефект.
|
||||||
|
|
||||||
|
#### Предел, который придётся закрыть импортом
|
||||||
|
|
||||||
|
В `export.xml` у элемента `Workout` идентификатора нет вовсе —
|
||||||
|
`dogsheep/healthkit-to-sqlite` поэтому адресует тренировку **хешем содержимого**
|
||||||
|
(`hash_id` в sqlite-utils). Значит `import` снапшота задвоит тренировки,
|
||||||
|
приехавшие от HAE: та же дыра, что у точек, где её закрыли ключом
|
||||||
|
`start + end`. Сегодня импорта нет, и решать это до его формы значило бы
|
||||||
|
угадывать; предел записан в беклоге отдельной задачей.
|
||||||
|
|
||||||
### Время
|
### Время
|
||||||
|
|
||||||
Точка внутри объекта хранится дословно, вместе с исходной строкой даты. Для
|
Точка внутри объекта хранится дословно, вместе с исходной строкой даты. Для
|
||||||
|
|||||||
@@ -21,7 +21,6 @@
|
|||||||
- [Порядок журнала при конкурентных приёмах](poryadok-zhurnala-na-priyome.md) — доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда — живое состояние расходится с reindex
|
- [Порядок журнала при конкурентных приёмах](poryadok-zhurnala-na-priyome.md) — доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда — живое состояние расходится с reindex
|
||||||
|
|
||||||
## высокий
|
## высокий
|
||||||
- [Тренировки и секции с собственными id](trenirovki-i-zapisi.md) — Тренировки с геотреком и состояние разума приходят, но не разбираются — без них не закрыть ни трекер, ни агента-медика
|
|
||||||
- [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое
|
- [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое
|
||||||
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
|
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
|
||||||
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
|
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
|
||||||
@@ -31,6 +30,7 @@
|
|||||||
- [Словарь категориальных значений → коды HealthKit](slovar-kategorialnyh-znachenij.md) — Фазы сна и типы тренировок приходят строками русской локали — с экспортом Apple их не сверить
|
- [Словарь категориальных значений → коды HealthKit](slovar-kategorialnyh-znachenij.md) — Фазы сна и типы тренировок приходят строками русской локали — с экспортом Apple их не сверить
|
||||||
- [Выведенные из данных схемы содержимого](samoopisanie-shemy.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
|
- [Выведенные из данных схемы содержимого](samoopisanie-shemy.md) — Метрик больше сотни и формы точек разные — клиент вынужден угадывать структуру по выборке
|
||||||
- [Импорт родного экспорта Apple Health](import-eksporta-apple.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
|
- [Импорт родного экспорта Apple Health](import-eksporta-apple.md) — Слой sample пуст: полная история и точные сэмплы лежат в zip-архиве и никуда не едут
|
||||||
|
- [Идентичность тренировок при импорте родного экспорта](identichnost-trenirovok-pri-importe.md) — В export.xml у тренировки нет id — импорт снапшота задвоит тренировки, приехавшие от HAE
|
||||||
- [Проверка секций, которых поток ещё не приносил](proverka-novyh-sekcij.md) — Симптомы, ЭКГ, лекарства, цикл и вес не виденны живьём — разбор писался вслепую
|
- [Проверка секций, которых поток ещё не приносил](proverka-novyh-sekcij.md) — Симптомы, ЭКГ, лекарства, цикл и вес не виденны живьём — разбор писался вслепую
|
||||||
- [Наблюдаемость: /stats](stats-nablyudaemost.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
|
- [Наблюдаемость: /stats](stats-nablyudaemost.md) — Тихо сломавшаяся автоматизация — главный эксплуатационный риск, а сейчас факт виден только в логах
|
||||||
- [Проверка целостности собранной витрины перед подменой](celostnost-pered-podmenoj.md) — Подмена файла необратима, а годность выхода подтверждена отпечатком на ещё открытом дескрипторе
|
- [Проверка целостности собранной витрины перед подменой](celostnost-pered-podmenoj.md) — Подмена файла необратима, а годность выхода подтверждена отпечатком на ещё открытом дескрипторе
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Идентичность тренировок при импорте родного экспорта
|
||||||
|
|
||||||
|
**Приоритет:** средний
|
||||||
|
|
||||||
|
Тренировка в витрине адресуется своим `id` из HealthKit — его шлёт HAE. В
|
||||||
|
`export.xml` этого идентификатора **нет вовсе**: у элемента `Workout` только
|
||||||
|
тип, источник, даты и статистика. Значит `healthlog import` не сможет сопоставить
|
||||||
|
тренировку из снапшота с той же тренировкой, уже приехавшей от HAE, и они
|
||||||
|
задвоятся.
|
||||||
|
|
||||||
|
Это ровно та дыра, что была у точек, и там её закрыли ключом `start + end`
|
||||||
|
(находка 47): `HKObject.uuid` в выгрузку не попадает, поэтому модель
|
||||||
|
идентичности обязана выражаться через интервал.
|
||||||
|
|
||||||
|
Prior art: `dogsheep/healthkit-to-sqlite` адресует тренировку **хешем
|
||||||
|
содержимого** (`hash_id` в sqlite-utils) — ровно потому, что идентификатора в
|
||||||
|
экспорте нет. Нам это не подходит в лоб: у нас половина тренировок уже лежит под
|
||||||
|
настоящим `id`, и хеш содержимого дал бы третий ключ рядом с двумя.
|
||||||
|
|
||||||
|
Варианты, которые надо будет сравнить:
|
||||||
|
|
||||||
|
- **Второй уникальный ключ `(start_utc, end_utc)`** у тренировки: импорт ищет по
|
||||||
|
нему, HAE — по `id`. Цена: индекс и вопрос, что делать при столкновении двух
|
||||||
|
разных тренировок с одним интервалом (бывает ли такое — неизвестно).
|
||||||
|
- **Сопоставление на стадии импорта**, без изменения схемы: импорт читает уже
|
||||||
|
сохранённые тренировки за период и приписывает найденным их `id`. Цена: логика
|
||||||
|
сопоставления живёт в импорте и не проверяется ничем, кроме него.
|
||||||
|
- **Считать тренировки из экспорта отдельным родом** и не сопоставлять вовсе.
|
||||||
|
Цена: потребитель видит две тренировки вместо одной и обязан схлопывать сам —
|
||||||
|
ровно то, чего проект старается не делать.
|
||||||
|
|
||||||
|
Решать до появления формы `healthlog import` значит угадывать: неизвестно,
|
||||||
|
понадобятся ли тренировки из экспорта вообще (у HAE они полнее — с маршрутом и
|
||||||
|
рядами, а в экспорте маршрут лежит отдельными GPX).
|
||||||
|
|
||||||
|
Связано: `docs/architecture.md` → «Тренировки и прочие секции», задача
|
||||||
|
`import-eksporta-apple`.
|
||||||
@@ -36,3 +36,10 @@
|
|||||||
секцию, которой раньше не было, теперь **фиксируется** — остаётся научиться
|
секцию, которой раньше не было, теперь **фиксируется** — остаётся научиться
|
||||||
замечать его активно: один `SELECT DISTINCT` по колонке даёт список всего, что
|
замечать его активно: один `SELECT DISTINCT` по колонке даёт список всего, что
|
||||||
поток приносил, и сравнение с известным набором закрывает задачу.
|
поток приносил, и сравнение с известным набором закрывает задачу.
|
||||||
|
|
||||||
|
Модель под секции с собственным `id` заложена (change
|
||||||
|
`2026-08-02-trenirovki-i-zapisi`): таблица `record` ключуется парой
|
||||||
|
`род + id`, и новая секция добавляется **одной строкой** в множество покрытых
|
||||||
|
имён разбора, а не миграцией. Покрыты `workouts` и `stateOfMind`; остались
|
||||||
|
`ecg`, `symptoms`, `cycleTracking`, `medications`, `heartRateNotifications` —
|
||||||
|
их формы никто не видел, и разбор вслепую сознательно не писался.
|
||||||
|
|||||||
@@ -14,8 +14,17 @@
|
|||||||
и не влезает — ошибка со списком доступных сеток, а не тихая подмена. Различие
|
и не влезает — ошибка со списком доступных сеток, а не тихая подмена. Различие
|
||||||
существенно: иначе агент, попросивший минутную сетку, получит суточные суммы.
|
существенно: иначе агент, попросивший минутную сетку, получит суточные суммы.
|
||||||
|
|
||||||
|
**Отдача тренировок и записей входит сюда же.** Разбор и хранение сущностей с
|
||||||
|
собственным `id` сделаны (change `2026-08-02-trenirovki-i-zapisi`), а эндпоинтов
|
||||||
|
нет: тренировка с маршрутом и записи `stateOfMind` лежат в витрине и наружу не
|
||||||
|
отдаются. Вводить их раньше конверта ответа значило бы задать контракт
|
||||||
|
мимоходом, поэтому `GET /workouts`, `GET /workouts/{id}` и
|
||||||
|
`GET /records/{kind}` закрываются этой задачей — вместе с формой конверта и
|
||||||
|
правилом размера ответа. Второй сценарий паспорта (трекер) до тех пор не закрыт.
|
||||||
|
|
||||||
Готово, когда «шаги за неделю по дням» и «вес за год» отвечаются одним запросом
|
Готово, когда «шаги за неделю по дням» и «вес за год» отвечаются одним запросом
|
||||||
каждый, а в ответе всегда видно `layer`, `bucket` и `aggregation`.
|
каждый, тренировка отдаётся одним пакетом вместе с маршрутом, а в ответе всегда
|
||||||
|
видно `layer`, `bucket` и `aggregation`.
|
||||||
|
|
||||||
Связано: `docs/architecture.md` → «Read API», план → шаг «Read API».
|
Связано: `docs/architecture.md` → «Read API», план → шаг «Read API».
|
||||||
|
|
||||||
|
|||||||
@@ -26,14 +26,38 @@
|
|||||||
экспорта, записи `stateOfMind` не трогаются вовсе, а `/stats` показывает
|
экспорта, записи `stateOfMind` не трогаются вовсе, а `/stats` показывает
|
||||||
глубину архива и дату снапшота, до которой он подрезан.
|
глубину архива и дату снапшота, до которой он подрезан.
|
||||||
|
|
||||||
## Предусловие снято
|
## Предусловие снова открыто
|
||||||
|
|
||||||
Признак, без которого ретеншен был опасен, готов: доставка с непокрытой секцией
|
Признак «доставка с непокрытой секцией» появился в change
|
||||||
имеет статус `partial` и список непокрытых ключей
|
`2026-08-01-nerazobrannye-sekcii-dostavki` и работал заодно защитой
|
||||||
(change `2026-08-01-nerazobrannye-sekcii-dostavki`). Ретеншен обязан спрашивать
|
`stateOfMind`: такие доставки числились `partial`, и ретеншен их не тронул бы.
|
||||||
статус, а не считать `parsed` разрешением: тело `stateOfMind` восстановить
|
|
||||||
неоткуда — в экспорте Apple секции нет.
|
Change `2026-08-02-trenirovki-i-zapisi` покрыл `stateOfMind` разбором, и защита
|
||||||
|
исчезла: доставка из одного состояния разума теперь получает `parsed` с пустым
|
||||||
|
списком непокрытых, то есть **побайтово неотличима** от доставки из метрик — а
|
||||||
|
метрики восстановимы из экспорта Apple, состояние разума нет (находка 46).
|
||||||
|
Ретеншен, написанный по правилу «удаляем всё, что не `partial`», сотрёт ровно те
|
||||||
|
тела, которых в экспорте не существует, и первая же пересборка потеряет историю
|
||||||
|
состояния разума навсегда.
|
||||||
|
|
||||||
|
Значит признак невосстановимости нужен **не производный от «непокрытости»**.
|
||||||
|
Варианты:
|
||||||
|
|
||||||
|
- **Перечень покрытых секций, которых нет в экспорте Apple** рядом с доставкой
|
||||||
|
(сегодня — ровно `stateOfMind`). Цена: колонка и строка в свёртке; читается
|
||||||
|
так же, как `uncovered_sections`, и одним запросом.
|
||||||
|
- **Признак у доставки «тело — единственный источник»**, выставляемый разбором.
|
||||||
|
Цена та же, но смысл шире и требует решения, что считать единственным
|
||||||
|
источником для будущих секций.
|
||||||
|
- **Никогда не подрезать тела доставок, у которых есть строки в `record`.**
|
||||||
|
Цена нулевая по схеме, но неточная: провенанс записи указывает на доставку
|
||||||
|
её **текущей** версии, а копий у записи бывает по 26.
|
||||||
|
|
||||||
|
Рекомендация — первый вариант: он прямо отвечает на вопрос «что останется
|
||||||
|
потерянным», как это уже делает `uncovered_sections`, и не требует додумывать
|
||||||
|
семантику.
|
||||||
|
|
||||||
Вместе с этим действует правило: задача, которая начинает разбирать секцию, тем
|
Вместе с этим действует правило: задача, которая начинает разбирать секцию, тем
|
||||||
же изменением переводит `partial`-строки с этим ключом в `pending`. Ретеншену
|
же изменением переводит `partial`-строки с этим ключом в `pending` (так сделала
|
||||||
позволено смотреть на `partial` только пока правило соблюдается.
|
миграция `00007`). Ретеншену позволено смотреть на `partial` только пока правило
|
||||||
|
соблюдается.
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
# Тренировки и секции с собственными id
|
|
||||||
|
|
||||||
**Приоритет:** высокий
|
|
||||||
|
|
||||||
Тренировки приезжают с геотреком, состояние разума — с кодами HealthKit. Ни то,
|
|
||||||
ни другое сейчас не разбирается. Тренировки нужны трекеру (второй сценарий),
|
|
||||||
состояние разума — агенту-медику.
|
|
||||||
|
|
||||||
Модель отличается от метрик: у этих сущностей есть собственный `id`, они редки,
|
|
||||||
и по часам их группировать незачем. Тренировка **перезаписывается** целиком —
|
|
||||||
она приезжает повторно, когда доедет маршрут.
|
|
||||||
|
|
||||||
Шаги:
|
|
||||||
- миграции `workout` и `record` (секции `stateOfMind`, `ecg`, `symptoms`,
|
|
||||||
`cycleTracking`, `medications`, `heartRateNotifications` — модель одна);
|
|
||||||
- заголовок тренировки колонками, маршрут и внутренние ряды — блобом;
|
|
||||||
- пульс внутри тренировки не смешивать с метрикой `heart_rate`: разные таблицы.
|
|
||||||
|
|
||||||
Готово, когда тренировка отдаётся одним пакетом вместе с маршрутом, а
|
|
||||||
`stateOfMind` виден записями.
|
|
||||||
|
|
||||||
Связано: `docs/architecture.md` → «Тренировки и прочие секции».
|
|
||||||
|
|
||||||
+16
-2
@@ -89,8 +89,22 @@
|
|||||||
(`internal/ident`). Сортируется по времени создания, удобен в логах и URL.
|
(`internal/ident`). Сортируется по времени создания, удобен в логах и URL.
|
||||||
Разбор внешнего id — `ident.Parse` на входной границе; синтаксически
|
Разбор внешнего id — `ident.Parse` на входной границе; синтаксически
|
||||||
невалидный id — 404 без похода в БД.
|
невалидный id — 404 без похода в БД.
|
||||||
- Естественный ключ вместо ULID там, где он есть по природе данных: `sample`
|
- Естественный ключ вместо ULID там, где он есть по природе данных: `workout` —
|
||||||
и `record` — по хешу содержимого, `workout` — по `id` из HealthKit.
|
по `id` из HealthKit, `record` — по паре `род секции + id` (форму
|
||||||
|
идентификатора у пяти из шести секций живьём никто не видел, и несквозной `id`
|
||||||
|
в двух секциях затёр бы одну запись другой молча).
|
||||||
|
- Новая единица хранения тем же изменением входит в **отпечаток витрины** и в
|
||||||
|
счётчики отчёта пересборки. Отпечаток отвечает «да/нет» за витрину целиком, и
|
||||||
|
единица, которой нет в счётчиках, делает расхождение безадресным: человек
|
||||||
|
видит «не совпало» при неизменившемся числе объектов и принимает по этому
|
||||||
|
необратимое решение о подмене базы.
|
||||||
|
- Правило выбора между двумя версиями одних данных объявляется либо **функцией
|
||||||
|
множества версий**, либо явно **функцией порядка журнала** — третьего
|
||||||
|
состояния нет. «Побеждает последняя пришедшая» третьим состоянием и является:
|
||||||
|
порядок свёртки порядку журнала не равен, и живая витрина расходится с
|
||||||
|
пересборкой молча.
|
||||||
|
- Любое значение из чужого JSON, попадающее в ключ, в лог или в отчёт, имеет
|
||||||
|
названный предел длины (имена непокрытых секций, `id` сущности).
|
||||||
- Временные метки — `TEXT` в RFC 3339, **UTC**, суффикс `Z`. Фиксированная
|
- Временные метки — `TEXT` в RFC 3339, **UTC**, суффикс `Z`. Фиксированная
|
||||||
ширина сохраняет лексикографическую сортировку = хронологию. Единая точка
|
ширина сохраняет лексикографическую сортировку = хронологию. Единая точка
|
||||||
генерации — `store.Now()`, а не дефолт в схеме: забытая вставка должна
|
генерации — `store.Now()`, а не дефолт в схеме: забытая вставка должна
|
||||||
|
|||||||
@@ -29,6 +29,22 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа
|
|||||||
│ derived_layer TEXT │ └──────────────────────────────┘
|
│ derived_layer TEXT │ └──────────────────────────────┘
|
||||||
│ uncovered_sections TEXT │
|
│ uncovered_sections TEXT │
|
||||||
└────────────────────────────┘
|
└────────────────────────────┘
|
||||||
|
┊ ┌──────────────────────────┐ ┌──────────────────────────┐
|
||||||
|
┊ │ workout │ │ record │
|
||||||
|
┊ доставка, │ ─────────────────────── │ │ ─────────────────────── │
|
||||||
|
└┄┄┄ чья версия ┄┄┄┄▶ │ id TEXT PK│ │ kind TEXT ┐ │
|
||||||
|
лежит сейчас │ name TEXT │ │ id TEXT ┘PK│
|
||||||
|
│ start_utc TEXT │ │ ts_utc TEXT │
|
||||||
|
│ end_utc TEXT │ │ tz_offset INTEGER│
|
||||||
|
│ tz_offset INTEGER│ │ payload BLOB │
|
||||||
|
│ duration_sec REAL? │ │ content_hash TEXT │
|
||||||
|
│ payload BLOB │ │ delivery_id TEXT │
|
||||||
|
│ content_hash TEXT │ │ delivery_received_at TEXT│
|
||||||
|
│ delivery_id TEXT │ │ created_at TEXT │
|
||||||
|
│ delivery_received_at TEXT│ │ updated_at TEXT │
|
||||||
|
│ created_at TEXT │ └──────────────────────────┘
|
||||||
|
│ updated_at TEXT │
|
||||||
|
└──────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
Связь `bucket.first_delivery_id → delivery.id` **внешним ключом не объявлена**
|
Связь `bucket.first_delivery_id → delivery.id` **внешним ключом не объявлена**
|
||||||
@@ -89,3 +105,41 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа
|
|||||||
`метрика + слой + начало + конец`, у точки-измерения конец равен началу.
|
`метрика + слой + начало + конец`, у точки-измерения конец равен началу.
|
||||||
`source` в ключ не входит: он нестабилен и переписывается задним числом. При
|
`source` в ключ не входит: он нестабилен и переписывается задним числом. При
|
||||||
столкновении выигрывает более полная точка, а не последняя пришедшая.
|
столкновении выигрывает более полная точка, а не последняя пришедшая.
|
||||||
|
|
||||||
|
## `workout` и `record` — сущности с собственным `id`
|
||||||
|
|
||||||
|
Вторая единица хранения витрины. Часовой объект им не подходит: у них есть
|
||||||
|
естественный ключ, они редки (за двое суток потока — две тренировки и две
|
||||||
|
записи состояния разума при 44 и 52 доставленных копиях), и группировать их по
|
||||||
|
часам незачем.
|
||||||
|
|
||||||
|
Таблицы две, а не одна с колонкой рода: у тренировки есть заголовок, по
|
||||||
|
которому идёт выборка (имя, интервал, длительность), а у записи его нет. Общая
|
||||||
|
таблица либо теряла бы заголовок, либо держала колонки, пустые у пяти родов из
|
||||||
|
шести.
|
||||||
|
|
||||||
|
| Колонка | Смысл |
|
||||||
|
|---|---|
|
||||||
|
| `workout.id` | идентификатор из HealthKit. Приходит из тела и ограничен по длине разбором: уезжает и в ключ, и в записи лога |
|
||||||
|
| `record.kind` + `record.id` | ключ — **пара**. Собственный `id` наблюдался живьём только у `stateOfMind`, где он UUID; форма идентификатора остальных пяти секций не наблюдалась никем, и короткий несквозной `id` в двух разных секциях затёр бы одну запись другой молча |
|
||||||
|
| `kind` | верхнеуровневый ключ секции HAE **дословно** (`stateOfMind`, не `state_of_mind`): инвариант «форма Apple не транслируется» относится и к именам секций |
|
||||||
|
| `start_utc` / `end_utc` / `ts_utc` | UTC RFC 3339. Конец, которого нет или который не читается, равен началу: ключ — `id`, схлопывать координаты нечем, а истина остаётся в `payload` |
|
||||||
|
| `tz_offset` | смещение зоны **начала**. У `stateOfMind` всегда `0` — это значит «источник прислал UTC», а не «человек был в Гринвиче»: местной зоны у секции в потоке нет вовсе. Клиент, считающий по нему местные сутки, ошибётся |
|
||||||
|
| `duration_sec` | длительность тренировки в секундах, как прислал HAE. **`NULL` означает «источник не прислал»**: ноль — законная длительность. Не вычисляется из интервала — HAE шлёт 91.746 при интервале в 91 секунду |
|
||||||
|
| `payload` | сущность целиком исходными байтами, gzip: заголовок, маршрут, внутренние ряды и сводки. Маршрут — 95% веса тренировки, а такой JSON жмётся примерно в 25 раз. Внутрь SQL-функциями не заглянуть — та же плата, что у `bucket.payload` |
|
||||||
|
| `content_hash` | хеш канонической формы: детектор изменений, не ключ. Тренировка переприсылается каждой доставкой, пока не доедет маршрут (44 копии дают три различных содержимых) |
|
||||||
|
| `delivery_id`, `delivery_received_at` | провенанс: доставка, **чья версия лежит сейчас**, и её метка приёма. Не отчётность: по паре разрешается тай-брейк между версиями равной полноты |
|
||||||
|
|
||||||
|
Индексы: `workout_start_utc` («заголовки тренировок за период» — основной запрос
|
||||||
|
трекера), `record_kind_ts` («записи такого-то рода за период» — единственная
|
||||||
|
форма запроса к таблице).
|
||||||
|
|
||||||
|
**Ряд пульса внутри тренировки лежит в её `payload`, а не в объектах метрики
|
||||||
|
`heart_rate`.** Пульс приезжает дважды — в общем потоке и внутри тренировки; это
|
||||||
|
разные таблицы, и смешение задвоило бы ряд.
|
||||||
|
|
||||||
|
**Замена версии условна.** Приехавшая побеждает, если не теряет содержания
|
||||||
|
сохранённой (множество ключей с непустым значением плюс длины верхнеуровневых
|
||||||
|
массивов); при равных наборах выигрывает версия из более поздней доставки
|
||||||
|
журнала, а не свёрнутая последней. Подробности и обоснование — в
|
||||||
|
`architecture.md`, раздел «Тренировки и прочие секции».
|
||||||
|
|||||||
@@ -1658,6 +1658,73 @@ apple_stand_time 14
|
|||||||
Отсюда статус `partial` и колонка `delivery.uncovered_sections`: статус
|
Отсюда статус `partial` и колонка `delivery.uncovered_sections`: статус
|
||||||
отвечает на вопрос «разобрано ли всё», список — «что именно осталось».
|
отвечает на вопрос «разобрано ли всё», список — «что именно осталось».
|
||||||
|
|
||||||
|
## 51. Тренировка досчитывается задним числом, но поля у неё только прибывают
|
||||||
|
|
||||||
|
Замер по всем 118 доставкам архива, группировка элементов секций по `id`:
|
||||||
|
|
||||||
|
| сущность | копий | различных содержимых | набор полей рос | набор полей убывал |
|
||||||
|
|---|---:|---:|---|---|
|
||||||
|
| тренировка A | 26 | 3 | да | нет |
|
||||||
|
| тренировка B | 18 | 1 | — | — |
|
||||||
|
| `stateOfMind` #1 | 26 | 1 | — | — |
|
||||||
|
| `stateOfMind` #2 | 26 | 1 | — | — |
|
||||||
|
|
||||||
|
Что менялось у тренировки A между версиями:
|
||||||
|
|
||||||
|
```
|
||||||
|
версия 0 → 1 +stepCadence, +stepCount, изменилось значение ряда activeEnergy
|
||||||
|
версия 1 → 2 набор полей тот же, изменились totalEnergy и basalEnergy
|
||||||
|
```
|
||||||
|
|
||||||
|
Два вывода, и оба вошли в правило замены версии.
|
||||||
|
|
||||||
|
**Тренировка правится задним числом ровно так же, как минутное ведро**
|
||||||
|
(находка 10): при неизменном наборе полей значения досчитываются. Значит
|
||||||
|
правило «при равной полноте побеждает тот, чья каноническая форма меньше» —
|
||||||
|
то, что действует для точек, — заморозило бы тренировку на произвольной версии
|
||||||
|
навсегда, вместе с недосчитанной энергией.
|
||||||
|
|
||||||
|
**Набор полей за весь корпус ни разу не уменьшился.** Обеднённая версия —
|
||||||
|
событие, которого поток не производит; но маршрут это 95% веса тренировки
|
||||||
|
(находка 22), а восстановление требует пересборки всего журнала. Поэтому
|
||||||
|
удержание сохранённой версии стоит одного сравнения множеств, а событие делается
|
||||||
|
наблюдаемым — счётчиком и `WARN`, — вместо необратимого.
|
||||||
|
|
||||||
|
**Правило полноты, написанное для точек, здесь неприменимо.** Оно требует, чтобы
|
||||||
|
значения общих содержательных ключей совпали, иначе отношение включения гасится
|
||||||
|
до «равенства». У точки это верно (надмножество имён при других значениях
|
||||||
|
означает другое измерение), у сущности — нет: значения между версиями
|
||||||
|
расходятся всегда. Проверено на копии пакета `canon`:
|
||||||
|
|
||||||
|
```
|
||||||
|
сохранённая с маршрутом vs обеднённая, значения общих полей те же : superset
|
||||||
|
сохранённая с маршрутом vs обеднённая, значения общих полей иные : equal
|
||||||
|
сохранённая vs версия с усечённым маршрутом (2 точки → 1) : equal
|
||||||
|
```
|
||||||
|
|
||||||
|
Отсюда же второй разряд правила: усечённый ряд ключа не теряет, поэтому
|
||||||
|
сравнивается ещё и длина верхнеуровневых массивов.
|
||||||
|
|
||||||
|
## 52. Половина потока — не `metrics`: перемер на 118 доставках
|
||||||
|
|
||||||
|
Пересчёт находки 50 на выросшем корпусе. Набор верхнеуровневых ключей `data`:
|
||||||
|
|
||||||
|
| набор ключей `data` | доставок |
|
||||||
|
|---|---|
|
||||||
|
| `metrics` | 65 |
|
||||||
|
| `workouts` | 27 |
|
||||||
|
| `stateOfMind` | 26 |
|
||||||
|
|
||||||
|
Пропорция та же, что была на 99 доставках (51/24/24), и наблюдение «ни одна
|
||||||
|
доставка не несла двух секций сразу» держится: автоматизация HAE шлёт одну
|
||||||
|
секцию за раз. Полагаться на это в правилах удаления данных по-прежнему нельзя —
|
||||||
|
за двое суток наблюдения смешанная доставка просто не успела бы случиться.
|
||||||
|
|
||||||
|
С покрытием `workouts` и `stateOfMind` разбором эти 53 доставки перестали быть
|
||||||
|
`partial`. Прогон живого архива после изменения: 118 тел, свёрнуто 118, отказов
|
||||||
|
ноль, частично разобранных ноль, в витрине 2049 часовых объектов, 2 тренировки и
|
||||||
|
2 записи; повторное проигрывание дало тот же отпечаток.
|
||||||
|
|
||||||
## Инструмент
|
## Инструмент
|
||||||
|
|
||||||
Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная
|
Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная
|
||||||
|
|||||||
@@ -246,6 +246,72 @@ func (f Fields) Relate(g Fields) Fullness {
|
|||||||
return FullnessEqual
|
return FullnessEqual
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Covers говорит, несёт ли f всё СОДЕРЖАНИЕ g: каждый содержательный ключ g
|
||||||
|
// есть у f, и ни один верхнеуровневый массив не стал короче.
|
||||||
|
//
|
||||||
|
// Отдельно от Relate, и это не дубль. Relate гасит отношение включения до
|
||||||
|
// FullnessEqual, когда значения общих содержательных ключей разошлись, — верно
|
||||||
|
// для точки (надмножество имён при других значениях означает другое
|
||||||
|
// измерение), но неверно для сущности с собственным `id`: у неё вторая версия
|
||||||
|
// есть тот же объект, пересчитанный источником, и значения между версиями
|
||||||
|
// расходятся ВСЕГДА. Приложи Relate к тренировке — и обеднённая версия
|
||||||
|
// получила бы «равенство» и заместила бы сохранённую вместе с маршрутом
|
||||||
|
// (95% её веса), а тест на фикстуре с неизменёнными значениями остался бы
|
||||||
|
// зелёным.
|
||||||
|
//
|
||||||
|
// Длина верхнеуровневых массивов сравнивается потому, что усечённый маршрут
|
||||||
|
// (три точки вместо 593) ключа не теряет. Досчёт ряды удлиняет, поэтому
|
||||||
|
// укорачивание — законный признак «приехало меньше». Предел правила назван
|
||||||
|
// вслух: сокращение ВНУТРИ элемента ряда (точка маршрута без altitude) не
|
||||||
|
// ловится ничем, кроме сверки с телом в архиве.
|
||||||
|
//
|
||||||
|
// Длины считаются здесь, а не в Analyze: Analyze зовётся на каждый кандидат
|
||||||
|
// слияния точек, и разбор heartbeatSeries на каждой точке стоил бы дороже
|
||||||
|
// самого сравнения.
|
||||||
|
func (f Fields) Covers(g Fields) bool {
|
||||||
|
for k, gv := range g.full {
|
||||||
|
fv, ok := f.full[k]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
gn, gok := arrayLen(gv)
|
||||||
|
if !gok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fn, fok := arrayLen(fv)
|
||||||
|
if !fok || fn < gn {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// arrayLen возвращает число элементов верхнеуровневого массива. Второй возврат
|
||||||
|
// — является ли значение массивом вообще.
|
||||||
|
//
|
||||||
|
// Элементы проглатываются в выбрасываемый RawMessage: считать нужно только
|
||||||
|
// количество, а материализация маршрута в дерево значений стоила бы того же,
|
||||||
|
// от чего отказался разбор тела.
|
||||||
|
func arrayLen(raw json.RawMessage) (int, bool) {
|
||||||
|
if len(bytes.TrimSpace(raw)) == 0 || bytes.TrimSpace(raw)[0] != '[' {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
if _, err := dec.Token(); err != nil { // открывающая скобка
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for dec.More() {
|
||||||
|
var skip json.RawMessage
|
||||||
|
if err := dec.Decode(&skip); err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
|
||||||
// agreeOnShared говорит, совпадают ли значения ключей, содержательных у обеих
|
// agreeOnShared говорит, совпадают ли значения ключей, содержательных у обеих
|
||||||
// точек. Сравнение каноническое: порядок ключей и дребезг последнего разряда
|
// точек. Сравнение каноническое: порядок ключей и дребезг последнего разряда
|
||||||
// расхождением не считаются.
|
// расхождением не считаются.
|
||||||
|
|||||||
+85
-28
@@ -65,26 +65,28 @@ func New(arch *archive.Archive, st *store.Store, maxBody int64, log *slog.Logger
|
|||||||
const finishTimeout = 10 * time.Second
|
const finishTimeout = 10 * time.Second
|
||||||
|
|
||||||
// Stats — итог свёртки одной доставки.
|
// Stats — итог свёртки одной доставки.
|
||||||
|
//
|
||||||
|
// Счётчики слияния ВСТРОЕНЫ, а не переписаны полем в поле: ручное копирование
|
||||||
|
// молча теряет новый счётчик, а по одному из них (удержанная обеднённая версия
|
||||||
|
// сущности) принято решение не объединять поля — забытая строка присваивания
|
||||||
|
// отменила бы наблюдение при зелёных тестах хранилища.
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
|
store.MergeStats
|
||||||
|
|
||||||
Metrics int
|
Metrics int
|
||||||
Points int
|
Points int
|
||||||
Stored int
|
|
||||||
Buckets int
|
|
||||||
Unchanged int
|
|
||||||
Overwrites int
|
|
||||||
Incomparable int
|
|
||||||
SealedHits int
|
|
||||||
UnitsConflicts int
|
|
||||||
SkippedNoTime int
|
SkippedNoTime int
|
||||||
SkippedMalformed int
|
SkippedMalformed int
|
||||||
SkippedBadEnd int
|
SkippedBadEnd int
|
||||||
Layer string
|
// Счётчики пропуска сущностей: у каждого класса свой, потому что тело в
|
||||||
LayerMismatch bool
|
// архиве остаётся, а вернуть сущность может только пересборка.
|
||||||
Collisions []store.Collision
|
SkippedNoID int
|
||||||
IncomparableAt []store.Collision
|
SkippedEntityNoTime int
|
||||||
|
SkippedEntityMalformed int
|
||||||
|
Layer string
|
||||||
|
LayerMismatch bool
|
||||||
// Uncovered — верхнеуровневые ключи `data`, которых разбор не покрывает.
|
// Uncovered — верхнеуровневые ключи `data`, которых разбор не покрывает.
|
||||||
// Это ответ на вопрос «что останется потерянным, если тело удалить»:
|
// Это ответ на вопрос «что останется потерянным, если тело удалить».
|
||||||
// для stateOfMind он необратим — в экспорте Apple этой секции нет.
|
|
||||||
Uncovered []string
|
Uncovered []string
|
||||||
// UncoveredDropped — сколько имён отброшено границей списка.
|
// UncoveredDropped — сколько имён отброшено границей списка.
|
||||||
UncoveredDropped int
|
UncoveredDropped int
|
||||||
@@ -158,24 +160,21 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
|
|||||||
stats.SkippedNoTime = parsed.SkippedNoTime
|
stats.SkippedNoTime = parsed.SkippedNoTime
|
||||||
stats.SkippedMalformed = parsed.SkippedMalformed
|
stats.SkippedMalformed = parsed.SkippedMalformed
|
||||||
stats.SkippedBadEnd = parsed.SkippedBadEnd
|
stats.SkippedBadEnd = parsed.SkippedBadEnd
|
||||||
|
stats.SkippedNoID = parsed.SkippedNoID
|
||||||
|
stats.SkippedEntityNoTime = parsed.SkippedEntityNoTime
|
||||||
|
stats.SkippedEntityMalformed = parsed.SkippedEntityMalformed
|
||||||
stats.Layer = string(parsed.Layer)
|
stats.Layer = string(parsed.Layer)
|
||||||
stats.LayerMismatch = parsed.LayerMismatch
|
stats.LayerMismatch = parsed.LayerMismatch
|
||||||
|
|
||||||
merge, err := s.store.MergePoints(ctx, toIncoming(parsed.Points), deliveryID)
|
merge, err := s.store.Merge(ctx, toIncoming(parsed), store.DeliveryRef{
|
||||||
|
ID: d.ID,
|
||||||
|
ReceivedAt: d.ReceivedAt,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.fail(ctx, deliveryID, err, parsed.Uncovered)
|
s.fail(ctx, deliveryID, err, parsed.Uncovered)
|
||||||
return stats, err
|
return stats, err
|
||||||
}
|
}
|
||||||
|
stats.MergeStats = merge
|
||||||
stats.Stored = merge.Stored
|
|
||||||
stats.Buckets = merge.Buckets
|
|
||||||
stats.Unchanged = merge.Unchanged
|
|
||||||
stats.Overwrites = merge.Overwrites
|
|
||||||
stats.Incomparable = merge.Incomparable
|
|
||||||
stats.SealedHits = merge.SealedHits
|
|
||||||
stats.UnitsConflicts = merge.UnitsConflicts
|
|
||||||
stats.Collisions = merge.Collisions
|
|
||||||
stats.IncomparableAt = merge.IncomparableAt
|
|
||||||
|
|
||||||
// Источник истины — список; статус производен от него и от факта отказа.
|
// Источник истины — список; статус производен от него и от факта отказа.
|
||||||
// Приоритет назван явно, иначе два будущих читателя (ретеншен и /stats)
|
// Приоритет назван явно, иначе два будущих читателя (ретеншен и /stats)
|
||||||
@@ -212,6 +211,7 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
|
|||||||
// значения.
|
// значения.
|
||||||
func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
|
func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
|
||||||
skipped := st.SkippedNoTime + st.SkippedMalformed + st.SkippedBadEnd
|
skipped := st.SkippedNoTime + st.SkippedMalformed + st.SkippedBadEnd
|
||||||
|
skippedEntities := st.SkippedNoID + st.SkippedEntityNoTime + st.SkippedEntityMalformed
|
||||||
|
|
||||||
attrs := []any{
|
attrs := []any{
|
||||||
"delivery_id", deliveryID,
|
"delivery_id", deliveryID,
|
||||||
@@ -228,6 +228,16 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
|
|||||||
"skipped_no_time", st.SkippedNoTime,
|
"skipped_no_time", st.SkippedNoTime,
|
||||||
"skipped_malformed", st.SkippedMalformed,
|
"skipped_malformed", st.SkippedMalformed,
|
||||||
"skipped_bad_end", st.SkippedBadEnd,
|
"skipped_bad_end", st.SkippedBadEnd,
|
||||||
|
// Сущности с собственным `id`: пришло, легло и удержано. Координаты
|
||||||
|
// (род и идентификатор) разрешены, содержимое — нет: маршрут
|
||||||
|
// тренировки это геотрек до дома, а метки состояния разума —
|
||||||
|
// измерение душевного состояния.
|
||||||
|
"workouts", st.Workouts,
|
||||||
|
"workouts_written", st.WorkoutsWritten,
|
||||||
|
"records", st.Records,
|
||||||
|
"records_written", st.RecordsWritten,
|
||||||
|
"entities_held", st.EntitiesHeld,
|
||||||
|
"skipped_entities", skippedEntities,
|
||||||
"layer", st.Layer,
|
"layer", st.Layer,
|
||||||
"layer_mismatch", st.LayerMismatch,
|
"layer_mismatch", st.LayerMismatch,
|
||||||
// Структурным []string, а не склейкой: JSON-кодировщик slog экранирует
|
// Структурным []string, а не склейкой: JSON-кодировщик slog экранирует
|
||||||
@@ -242,13 +252,26 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
|
|||||||
if len(st.IncomparableAt) > 0 {
|
if len(st.IncomparableAt) > 0 {
|
||||||
attrs = append(attrs, "incomparable_at", formatCollisions(st.IncomparableAt))
|
attrs = append(attrs, "incomparable_at", formatCollisions(st.IncomparableAt))
|
||||||
}
|
}
|
||||||
|
if len(st.HeldAt) > 0 {
|
||||||
|
attrs = append(attrs, "held_at", formatEntityRefs(st.HeldAt))
|
||||||
|
}
|
||||||
|
|
||||||
// Доставка, у которой отброшены ВСЕ точки, — это сломавшийся формат, а не
|
// Доставка, у которой отброшены ВСЕ точки, — это сломавшийся формат, а не
|
||||||
// штатная работа. Без этого условия смена формата метки выглядела бы как
|
// штатная работа. Без этого условия смена формата метки выглядела бы как
|
||||||
// здоровый поток: 200, parsed, INFO, points=0.
|
// здоровый поток: 200, parsed, INFO, points=0.
|
||||||
allSkipped := st.Points == 0 && skipped > 0
|
allSkipped := st.Points == 0 && skipped > 0
|
||||||
|
// То же для сущностей: доставка из одних тренировок, у которой не осталось
|
||||||
|
// ни одной, — это сменившийся формат, а не пустая секция.
|
||||||
|
allEntitiesSkipped := st.Workouts == 0 && st.Records == 0 && skippedEntities > 0
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
|
case st.EntitiesHeld > 0:
|
||||||
|
// Приехавшая версия сущности отклонена как теряющая содержание. Плата
|
||||||
|
// за отказ объединять поля: событие обязано быть видно, потому что на
|
||||||
|
// живом потоке оно не наступало ни разу и правило держится на этом.
|
||||||
|
s.log.WarnContext(ctx, "delivery folded, poorer entity version held", attrs...)
|
||||||
|
case allEntitiesSkipped:
|
||||||
|
s.log.WarnContext(ctx, "delivery folded, all entities skipped", attrs...)
|
||||||
case st.UncoveredDropped > 0:
|
case st.UncoveredDropped > 0:
|
||||||
// Не частичный разбор, а тело, не похожее на HAE: секций у HAE восемь,
|
// Не частичный разбор, а тело, не похожее на HAE: секций у HAE восемь,
|
||||||
// а границу выбило больше тридцати двух.
|
// а границу выбило больше тридцати двух.
|
||||||
@@ -288,6 +311,16 @@ func formatCollisions(cs []store.Collision) string {
|
|||||||
return strings.Join(parts, " ")
|
return strings.Join(parts, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatEntityRefs превращает координаты сущностей в строку для лога.
|
||||||
|
// Содержимого не несёт: род и идентификатор — координаты, а не измерение.
|
||||||
|
func formatEntityRefs(refs []store.EntityRef) string {
|
||||||
|
parts := make([]string, 0, len(refs))
|
||||||
|
for _, r := range refs {
|
||||||
|
parts = append(parts, r.Kind+"/"+r.ID)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
// keepLayer — значение слоя, означающее «оставить как было».
|
// keepLayer — значение слоя, означающее «оставить как было».
|
||||||
const keepLayer = ""
|
const keepLayer = ""
|
||||||
|
|
||||||
@@ -379,10 +412,10 @@ func (s *Service) readBody(rawPath string) ([]byte, error) {
|
|||||||
return body, nil
|
return body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func toIncoming(points []hae.Point) []store.IncomingPoint {
|
func toIncoming(parsed hae.Result) store.Incoming {
|
||||||
out := make([]store.IncomingPoint, 0, len(points))
|
points := make([]store.IncomingPoint, 0, len(parsed.Points))
|
||||||
for _, p := range points {
|
for _, p := range parsed.Points {
|
||||||
out = append(out, store.IncomingPoint{
|
points = append(points, store.IncomingPoint{
|
||||||
Metric: p.Metric,
|
Metric: p.Metric,
|
||||||
Layer: string(p.Layer),
|
Layer: string(p.Layer),
|
||||||
Units: p.Units,
|
Units: p.Units,
|
||||||
@@ -394,5 +427,29 @@ func toIncoming(points []hae.Point) []store.IncomingPoint {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
return store.Incoming{
|
||||||
|
Points: points,
|
||||||
|
Workouts: toEntities(parsed.Workouts),
|
||||||
|
Records: toEntities(parsed.Records),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toEntities(in []hae.Entity) []store.IncomingEntity {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]store.IncomingEntity, 0, len(in))
|
||||||
|
for _, e := range in {
|
||||||
|
out = append(out, store.IncomingEntity{
|
||||||
|
ID: e.ID,
|
||||||
|
Kind: e.Kind,
|
||||||
|
Name: e.Name,
|
||||||
|
Start: e.Start,
|
||||||
|
End: e.End,
|
||||||
|
OffsetSeconds: e.OffsetSeconds,
|
||||||
|
Duration: e.Duration,
|
||||||
|
Raw: e.Raw,
|
||||||
|
})
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,8 +257,8 @@ func TestFoldЧастичныйРазборВиденВУчёте(t *testing.T)
|
|||||||
if d.ParseStatus != store.ParsePartial {
|
if d.ParseStatus != store.ParsePartial {
|
||||||
t.Errorf("статус %q, ожидался %q", d.ParseStatus, store.ParsePartial)
|
t.Errorf("статус %q, ожидался %q", d.ParseStatus, store.ParsePartial)
|
||||||
}
|
}
|
||||||
if !strings.Contains(d.UncoveredSections, "stateOfMind") {
|
if !strings.Contains(d.UncoveredSections, "ecg") {
|
||||||
t.Errorf("список в базе %q не содержит stateOfMind", d.UncoveredSections)
|
t.Errorf("список в базе %q не содержит непокрытой секции", d.UncoveredSections)
|
||||||
}
|
}
|
||||||
// Точки метрик обязаны сохраниться: частичность не отменяет разобранного.
|
// Точки метрик обязаны сохраниться: частичность не отменяет разобранного.
|
||||||
if stats.Points == 0 {
|
if stats.Points == 0 {
|
||||||
|
|||||||
+130
-5
@@ -221,7 +221,7 @@ func TestFoldЧастичныйРазборВЛоге(t *testing.T) {
|
|||||||
{"date":"2025-06-05 10:07:00 +0300","qty":8},
|
{"date":"2025-06-05 10:07:00 +0300","qty":8},
|
||||||
{"date":"2025-06-05 10:08:00 +0300","qty":9},
|
{"date":"2025-06-05 10:08:00 +0300","qty":9},
|
||||||
{"date":"2025-06-05 10:09:00 +0300","qty":10}]}],
|
{"date":"2025-06-05 10:09:00 +0300","qty":10}]}],
|
||||||
"stateOfMind":[{"valence":"СЕКРЕТНОЕ-НАСТРОЕНИЕ"}]}}`
|
"ecg":[{"classification":"СЕКРЕТНЫЙ-РИТМ"}]}}`
|
||||||
|
|
||||||
deliver(t, arch, st, "d1", "Minutes", "a1", []byte(body))
|
deliver(t, arch, st, "d1", "Minutes", "a1", []byte(body))
|
||||||
if _, err := f.Fold(ctx, "d1"); err != nil {
|
if _, err := f.Fold(ctx, "d1"); err != nil {
|
||||||
@@ -229,10 +229,10 @@ func TestFoldЧастичныйРазборВЛоге(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
out := buf.String()
|
out := buf.String()
|
||||||
if !strings.Contains(out, "stateOfMind") {
|
if !strings.Contains(out, "ecg") {
|
||||||
t.Error("имени непокрытой секции нет в логе — момент появления новой секции незаметен")
|
t.Error("имени непокрытой секции нет в логе — момент появления новой секции незаметен")
|
||||||
}
|
}
|
||||||
if strings.Contains(out, "СЕКРЕТНОЕ-НАСТРОЕНИЕ") {
|
if strings.Contains(out, "СЕКРЕТНЫЙ-РИТМ") {
|
||||||
t.Error("содержимое непокрытой секции утекло в лог")
|
t.Error("содержимое непокрытой секции утекло в лог")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,7 +250,132 @@ func TestFoldЧастичныйРазборВЛоге(t *testing.T) {
|
|||||||
if rec.Level != "INFO" {
|
if rec.Level != "INFO" {
|
||||||
t.Errorf("уровень %q, ожидался INFO: частичность — не отклонение", rec.Level)
|
t.Errorf("уровень %q, ожидался INFO: частичность — не отклонение", rec.Level)
|
||||||
}
|
}
|
||||||
if len(rec.Uncovered) != 1 || rec.Uncovered[0] != "stateOfMind" {
|
if len(rec.Uncovered) != 1 || rec.Uncovered[0] != "ecg" {
|
||||||
t.Errorf("атрибут uncovered = %v, ожидался структурный список из stateOfMind", rec.Uncovered)
|
t.Errorf("атрибут uncovered = %v, ожидался структурный список из ecg", rec.Uncovered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Содержимое сущности чувствительнее значения точки: маршрут тренировки — это
|
||||||
|
// геотрек до дома, а метки состояния разума — измерение душевного состояния.
|
||||||
|
// Разрешены только координаты: род, идентификатор, интервал.
|
||||||
|
func TestFoldНеПишетСодержимогоСущностейВЛог(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
arch, err := archive.New(filepath.Join(dir, "raw"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("архив: %v", err)
|
||||||
|
}
|
||||||
|
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("база: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = st.Close() })
|
||||||
|
|
||||||
|
f := fold.New(arch, st, 0, log)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const full = `{"data":{"workouts":[{"id":"w-открытый","name":"На улице Ходьба",` +
|
||||||
|
`"start":"2025-06-05 10:00:00 +0300","end":"2025-06-05 10:10:00 +0300",` +
|
||||||
|
`"route":[{"latitude":55.987654,"longitude":37.123456},{"latitude":55.987655,"longitude":37.123457}],` +
|
||||||
|
`"totalEnergy":{"qty":404040.4}}],` +
|
||||||
|
`"stateOfMind":[{"id":"e-открытый","start":"2025-06-05T18:00:00Z",` +
|
||||||
|
`"labels":["СЕКРЕТНАЯ-ЭМОЦИЯ"],"valence":0.777777}]}}`
|
||||||
|
|
||||||
|
// Вторая доставка теряет маршрут и меняет значения — та самая ветка, где
|
||||||
|
// пишется WARN об удержанной версии и где велик соблазн приписать «что
|
||||||
|
// именно потерялось».
|
||||||
|
const poorer = `{"data":{"workouts":[{"id":"w-открытый","name":"На улице Ходьба",` +
|
||||||
|
`"start":"2025-06-05 10:00:00 +0300","end":"2025-06-05 10:10:00 +0300",` +
|
||||||
|
`"totalEnergy":{"qty":505050.5}}]}}`
|
||||||
|
|
||||||
|
deliver(t, arch, st, "d1", "Minutes", "auto-1", []byte(full))
|
||||||
|
if _, err := f.Fold(ctx, "d1"); err != nil {
|
||||||
|
t.Fatalf("свёртка первой доставки: %v", err)
|
||||||
|
}
|
||||||
|
deliver(t, arch, st, "d2", "Minutes", "auto-1", []byte(poorer))
|
||||||
|
if _, err := f.Fold(ctx, "d2"); err != nil {
|
||||||
|
t.Fatalf("свёртка второй доставки: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logged := buf.String()
|
||||||
|
for _, secret := range []string{"55.98", "37.12", "latitude", "СЕКРЕТНАЯ-ЭМОЦИЯ", "404040", "505050", "0.777777"} {
|
||||||
|
if strings.Contains(logged, secret) {
|
||||||
|
t.Errorf("в логе оказалось %q:\n%s", secret, logged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Координаты, наоборот, обязаны быть: без них счётчик удержанных версий не
|
||||||
|
// говорит, какая сущность пострадала.
|
||||||
|
if !strings.Contains(logged, "w-открытый") {
|
||||||
|
t.Error("координат удержанной сущности в логе нет")
|
||||||
|
}
|
||||||
|
if !strings.Contains(logged, "poorer entity version held") {
|
||||||
|
t.Error("удержание обеднённой версии не отмечено записью WARN")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Счётчики разбора доезжают до лога ручным присваиванием, и забытая строка
|
||||||
|
// молча выключила бы наблюдение — тот самый класс, ради которого счётчики
|
||||||
|
// слияния встроены структурой. Тест закрепляет имена атрибутов.
|
||||||
|
func TestFoldСчётчикиСущностейДоезжаютДоЛога(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
arch, err := archive.New(filepath.Join(dir, "raw"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("архив: %v", err)
|
||||||
|
}
|
||||||
|
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("база: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = st.Close() })
|
||||||
|
|
||||||
|
f := fold.New(arch, st, 0, log)
|
||||||
|
|
||||||
|
// Одна годная тренировка, одна без `id`, одна с неразбираемой меткой и один
|
||||||
|
// элемент, не являющийся объектом: три класса пропуска плюс успех.
|
||||||
|
const body = `{"data":{"workouts":[
|
||||||
|
{"id":"w1","start":"2025-06-05 10:00:00 +0300","end":"2025-06-05 10:10:00 +0300"},
|
||||||
|
{"start":"2025-06-05 11:00:00 +0300"},
|
||||||
|
{"id":"w3","start":"позавчера"},
|
||||||
|
"строка вместо объекта"]}}`
|
||||||
|
|
||||||
|
deliver(t, arch, st, "d1", "Minutes", "auto-1", []byte(body))
|
||||||
|
if _, err := f.Fold(context.Background(), "d1"); err != nil {
|
||||||
|
t.Fatalf("свёртка: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rec struct {
|
||||||
|
Workouts int `json:"workouts"`
|
||||||
|
WorkoutsWritten int `json:"workouts_written"`
|
||||||
|
Records int `json:"records"`
|
||||||
|
RecordsWritten int `json:"records_written"`
|
||||||
|
EntitiesHeld int `json:"entities_held"`
|
||||||
|
SkippedEntities int `json:"skipped_entities"`
|
||||||
|
}
|
||||||
|
line := strings.TrimSpace(buf.String())
|
||||||
|
if i := strings.LastIndex(line, "\n"); i >= 0 {
|
||||||
|
line = line[i+1:]
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||||
|
t.Fatalf("запись лога не разбирается: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Workouts != 1 || rec.WorkoutsWritten != 1 {
|
||||||
|
t.Errorf("тренировок %d, записано %d — ожидалось 1 и 1", rec.Workouts, rec.WorkoutsWritten)
|
||||||
|
}
|
||||||
|
if rec.SkippedEntities != 3 {
|
||||||
|
t.Errorf("пропущено сущностей %d, ожидалось 3 — счётчик не доехал до лога", rec.SkippedEntities)
|
||||||
|
}
|
||||||
|
if rec.Records != 0 || rec.RecordsWritten != 0 || rec.EntitiesHeld != 0 {
|
||||||
|
t.Errorf("лишние счётчики: записей %d/%d, удержано %d",
|
||||||
|
rec.Records, rec.RecordsWritten, rec.EntitiesHeld)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+165
-43
@@ -95,12 +95,66 @@ type Point struct {
|
|||||||
local time.Time
|
local time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Entity — сущность с собственным идентификатором: тренировка или запись
|
||||||
|
// секции вроде stateOfMind. От точки отличается тем, что её адресует сам `id`,
|
||||||
|
// а не координаты, и слоя у неё нет вовсе: подробности выгрузки у этих секций
|
||||||
|
// в интерфейсе HAE не бывает.
|
||||||
|
type Entity struct {
|
||||||
|
// ID — идентификатор из HealthKit. Приходит из тела и ограничен по длине:
|
||||||
|
// уезжает и в первичный ключ, и в записи лога.
|
||||||
|
ID string
|
||||||
|
// Kind — верхнеуровневый ключ секции HAE ДОСЛОВНО (`stateOfMind`, не
|
||||||
|
// `state_of_mind`): инвариант «форма Apple не транслируется» относится и к
|
||||||
|
// именам секций, а переименование после того, как значение легло в базу,
|
||||||
|
// стоило бы миграции данных. У тренировки род один и в ключ не входит.
|
||||||
|
Kind string
|
||||||
|
// Name — имя тренировки как прислал HAE, локализованное («В помещении
|
||||||
|
// Ходьба»). У записей пустое.
|
||||||
|
Name string
|
||||||
|
|
||||||
|
// Start и End — координаты в UTC. Конец, которого нет или который не
|
||||||
|
// читается, равен началу: ключ сущности — `id`, схлопывать нечего, а истина
|
||||||
|
// остаётся в Raw. У точки то же вырождение запрещено — там оно схлопнуло бы
|
||||||
|
// две записи в одну координату.
|
||||||
|
Start time.Time
|
||||||
|
End time.Time
|
||||||
|
|
||||||
|
// OffsetSeconds — смещение зоны НАЧАЛА. Колонка одна, а тренировка через
|
||||||
|
// смену зоны дала бы два разных.
|
||||||
|
OffsetSeconds int
|
||||||
|
|
||||||
|
// Duration — длительность тренировки в секундах, как прислал HAE. Не
|
||||||
|
// вычисляется из интервала: HAE шлёт 91.746 при интервале в 91 секунду.
|
||||||
|
// Отсутствие выражается nil, а не нулём: ноль — законная длительность.
|
||||||
|
Duration *float64
|
||||||
|
|
||||||
|
// Raw — содержимое сущности исходными байтами, как пришло в теле, включая
|
||||||
|
// маршрут и внутренние ряды.
|
||||||
|
Raw json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
// Result — итог разбора доставки. Частичные исходы живут в счётчиках, а не в
|
// Result — итог разбора доставки. Частичные исходы живут в счётчиках, а не в
|
||||||
// ошибке: пакет, у которого не разобралась одна точка из тысячи, — обычное
|
// ошибке: пакет, у которого не разобралась одна точка из тысячи, — обычное
|
||||||
// дело, и терять из-за неё остальное нельзя.
|
// дело, и терять из-за неё остальное нельзя.
|
||||||
type Result struct {
|
type Result struct {
|
||||||
Points []Point
|
Points []Point
|
||||||
|
|
||||||
|
// Workouts и Records — сущности с собственным идентификатором. Разведены,
|
||||||
|
// потому что у тренировки есть заголовок (имя, интервал, длительность), по
|
||||||
|
// которому идёт выборка, а у записи его нет.
|
||||||
|
Workouts []Entity
|
||||||
|
Records []Entity
|
||||||
|
|
||||||
|
// SkippedNoID — сущности без пригодного идентификатора: пустого, нет вовсе
|
||||||
|
// или длиннее предела. Один счётчик на все три случая: исход у них общий, а
|
||||||
|
// различает их только тело, лежащее в архиве.
|
||||||
|
SkippedNoID int
|
||||||
|
// SkippedEntityNoTime — сущности с идентификатором, но без разбираемой
|
||||||
|
// метки времени.
|
||||||
|
SkippedEntityNoTime int
|
||||||
|
// SkippedEntityMalformed — элементы секции, не разобравшиеся как объект.
|
||||||
|
SkippedEntityMalformed int
|
||||||
|
|
||||||
// Uncovered — верхнеуровневые ключи `data`, которых разбор не покрывает,
|
// Uncovered — верхнеуровневые ключи `data`, которых разбор не покрывает,
|
||||||
// отсортированные и без повторов. Половина живого потока состоит из таких
|
// отсортированные и без повторов. Половина живого потока состоит из таких
|
||||||
// доставок целиком (48 из 99: workouts и stateOfMind), и без этого списка
|
// доставок целиком (48 из 99: workouts и stateOfMind), и без этого списка
|
||||||
@@ -170,13 +224,17 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
metrics, uncovered, dropped, err := decodeEnvelope(body)
|
env, err := decodeEnvelope(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
res.Uncovered = uncovered
|
res.Uncovered = env.uncovered
|
||||||
res.UncoveredDropped = dropped
|
res.UncoveredDropped = env.dropped
|
||||||
|
|
||||||
|
res.Workouts = decodeEntities(env.workouts, workoutsSection, &res)
|
||||||
|
res.Records = decodeEntities(env.stateOfMind, stateOfMindSection, &res)
|
||||||
|
|
||||||
|
metrics := env.metrics
|
||||||
res.Metrics = len(metrics)
|
res.Metrics = len(metrics)
|
||||||
if len(metrics) == 0 {
|
if len(metrics) == 0 {
|
||||||
return res, nil
|
return res, nil
|
||||||
@@ -209,6 +267,12 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
|
|||||||
// определился слой, обязана остаться записью о том, что в теле есть
|
// определился слой, обязана остаться записью о том, что в теле есть
|
||||||
// невосстановимая секция. Иначе ретеншен увидит failed без списка и
|
// невосстановимая секция. Иначе ретеншен увидит failed без списка и
|
||||||
// решит, что терять нечего.
|
// решит, что терять нечего.
|
||||||
|
//
|
||||||
|
// Сущности при этом НЕ отдаются, хотя слоя у них нет и разобрались они
|
||||||
|
// успешно. «Всё или ничего» относится к доставке, а не к точкам: отдай
|
||||||
|
// мы их, доставка получила бы `failed` при частично записанной витрине,
|
||||||
|
// и повторная свёртка перестала бы быть no-op. Цена названа в спеке —
|
||||||
|
// такая доставка доедет пересборкой, а тело ждёт в архиве.
|
||||||
return Result{
|
return Result{
|
||||||
Metrics: res.Metrics,
|
Metrics: res.Metrics,
|
||||||
Uncovered: res.Uncovered,
|
Uncovered: res.Uncovered,
|
||||||
@@ -262,16 +326,53 @@ type group struct {
|
|||||||
// 42 МиБ через map[string]any удерживает 197 МиБ кучи против 54 МиБ у этой
|
// 42 МиБ через map[string]any удерживает 197 МиБ кучи против 54 МиБ у этой
|
||||||
// формы. Вместе с самим телом пик доходил бы до ~300 МиБ на доставку — это
|
// формы. Вместе с самим телом пик доходил бы до ~300 МиБ на доставку — это
|
||||||
// OOM ровно на пике потока, когда терять доставки дороже всего.
|
// OOM ровно на пике потока, когда терять доставки дороже всего.
|
||||||
// metricsSection — единственная секция, которую разбор покрывает сегодня.
|
// Секции, которые разбор покрывает. Прочие секции с собственными `id` (`ecg`,
|
||||||
const metricsSection = "metrics"
|
// `symptoms`, `cycleTracking`, `medications`, `heartRateNotifications`)
|
||||||
|
// покрытыми намеренно не становятся: живой поток не приносил их ни разу, их
|
||||||
|
// форма никем не наблюдалась, а полнота покрытия HealthKit ради полноты целью
|
||||||
|
// проекта не является.
|
||||||
|
const (
|
||||||
|
metricsSection = "metrics"
|
||||||
|
workoutsSection = "workouts"
|
||||||
|
stateOfMindSection = "stateOfMind"
|
||||||
|
)
|
||||||
|
|
||||||
// covered говорит, покрывает ли разбор секцию с таким именем.
|
// decodeCovered разбирает секцию, если разбор её покрывает; второй возврат
|
||||||
|
// говорит, взялся ли он за неё.
|
||||||
//
|
//
|
||||||
// Функция, а не изменяемая карта: разбор и перечисление непокрытых ходят по
|
// Один источник и для разбора, и для перечисления непокрытых: перечисляющий
|
||||||
// одному источнику, поэтому состояние «секция разбирается, но числится
|
// спрашивает ровно того, кто разбирает, поэтому состояние «секция разбирается,
|
||||||
// непокрытой» невыразимо.
|
// но числится непокрытой» невыразимо по построению. Отдельный предикат
|
||||||
func covered(section string) bool {
|
// `covered` разошёлся бы с этим switch при первой же новой секции.
|
||||||
return section == metricsSection
|
//
|
||||||
|
// Повтор ключа покрытой секции JSON допускает; секции ОБЪЕДИНЯЮТСЯ, а не
|
||||||
|
// побеждает последняя: терять данные молча нельзя.
|
||||||
|
func decodeCovered(name string, dec *json.Decoder, env *envelope) (bool, error) {
|
||||||
|
switch name {
|
||||||
|
case metricsSection:
|
||||||
|
var part []metricEnvelope
|
||||||
|
if err := dec.Decode(&part); err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
env.metrics = append(env.metrics, part...)
|
||||||
|
return true, nil
|
||||||
|
case workoutsSection:
|
||||||
|
part, err := decodeSection(dec)
|
||||||
|
if err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
env.workouts = append(env.workouts, part...)
|
||||||
|
return true, nil
|
||||||
|
case stateOfMindSection:
|
||||||
|
part, err := decodeSection(dec)
|
||||||
|
if err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
env.stateOfMind = append(env.stateOfMind, part...)
|
||||||
|
return true, nil
|
||||||
|
default:
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Границы на список непокрытых ключей. Тело контролирует отправитель целиком:
|
// Границы на список непокрытых ключей. Тело контролирует отправитель целиком:
|
||||||
@@ -317,9 +418,11 @@ type pointHead struct {
|
|||||||
// мгновенно. Разбор `data` в map[string]json.RawMessage дешевле по коду, но
|
// мгновенно. Разбор `data` в map[string]json.RawMessage дешевле по коду, но
|
||||||
// копирует байты ВСЕХ секций и держит их до конца разбора; у проглатывания
|
// копирует байты ВСЕХ секций и держит их до конца разбора; у проглатывания
|
||||||
// копия одна и живёт до следующего члена.
|
// копия одна и живёт до следующего члена.
|
||||||
func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string, dropped int, err error) {
|
func decodeEnvelope(body []byte) (envelope, error) {
|
||||||
fail := func(e error) ([]metricEnvelope, []string, int, error) {
|
var env envelope
|
||||||
return nil, nil, 0, fmt.Errorf("%w: %v", ErrMalformed, e) //nolint:errorlint // причина уходит в лог, наружу не раскрывается
|
|
||||||
|
fail := func(e error) (envelope, error) {
|
||||||
|
return envelope{}, fmt.Errorf("%w: %v", ErrMalformed, e) //nolint:errorlint // причина уходит в лог, наружу не раскрывается
|
||||||
}
|
}
|
||||||
|
|
||||||
dec := json.NewDecoder(bytes.NewReader(body))
|
dec := json.NewDecoder(bytes.NewReader(body))
|
||||||
@@ -335,7 +438,7 @@ func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string,
|
|||||||
// раскладывал его в пустую структуру. Границы поведения этой задачей не
|
// раскладывал его в пустую структуру. Границы поведения этой задачей не
|
||||||
// двигаются — она добавляет список, а не строгость.
|
// двигаются — она добавляет список, а не строгость.
|
||||||
if tok == nil {
|
if tok == nil {
|
||||||
return nil, nil, 0, nil
|
return envelope{}, nil
|
||||||
}
|
}
|
||||||
if d, ok := tok.(json.Delim); !ok || d != '{' {
|
if d, ok := tok.(json.Delim); !ok || d != '{' {
|
||||||
return fail(fmt.Errorf("ожидался объект, встречено %v", tok))
|
return fail(fmt.Errorf("ожидался объект, встречено %v", tok))
|
||||||
@@ -352,8 +455,12 @@ func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string,
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
metrics, uncovered, dropped, err = decodeData(dec, seen)
|
// Повтор самого члена `data` JSON допускает, и результаты
|
||||||
if err != nil {
|
// НАКАПЛИВАЮТСЯ, а не замещаются: присваивание теряло бы секции первого
|
||||||
|
// члена целиком, причём молча — их имена уже отмечены в `seen` и во
|
||||||
|
// второй список непокрытых не попали бы. Правило то же, что уровнем
|
||||||
|
// ниже для повтора ключа секции.
|
||||||
|
if err := decodeData(dec, seen, &env); err != nil {
|
||||||
return fail(err)
|
return fail(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -363,60 +470,75 @@ func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string,
|
|||||||
|
|
||||||
// Список канонизируется: порядок ключей в JSON от HAE нестабилен, а
|
// Список канонизируется: порядок ключей в JSON от HAE нестабилен, а
|
||||||
// значение уезжает в базу и сравнивается между доставками.
|
// значение уезжает в базу и сравнивается между доставками.
|
||||||
sort.Strings(uncovered)
|
sort.Strings(env.uncovered)
|
||||||
return metrics, uncovered, dropped, nil
|
return env, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// decodeData разбирает объект data, собирая metrics и имена непокрытых секций.
|
// envelope — что разбор вынул из тела: покрытые секции и имена непокрытых.
|
||||||
func decodeData(dec *json.Decoder, seen map[string]struct{}) ([]metricEnvelope, []string, int, error) {
|
type envelope struct {
|
||||||
|
metrics []metricEnvelope
|
||||||
|
workouts []json.RawMessage
|
||||||
|
stateOfMind []json.RawMessage
|
||||||
|
uncovered []string
|
||||||
|
dropped int
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeData разбирает объект data, дописывая в конверт покрытые секции и
|
||||||
|
// имена непокрытых.
|
||||||
|
func decodeData(dec *json.Decoder, seen map[string]struct{}, env *envelope) error {
|
||||||
tok, err := dec.Token()
|
tok, err := dec.Token()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, 0, err
|
return err
|
||||||
}
|
}
|
||||||
// data не объект — прежнее поведение: ошибка ровно там, где была.
|
// data не объект — прежнее поведение: ошибка ровно там, где была.
|
||||||
if d, ok := tok.(json.Delim); !ok || d != '{' {
|
if d, ok := tok.(json.Delim); !ok || d != '{' {
|
||||||
return nil, nil, 0, fmt.Errorf("data: ожидался объект, встречено %v", tok)
|
return fmt.Errorf("data: ожидался объект, встречено %v", tok)
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
|
||||||
metrics []metricEnvelope
|
|
||||||
uncovered []string
|
|
||||||
dropped int
|
|
||||||
)
|
|
||||||
for dec.More() {
|
for dec.More() {
|
||||||
name, err := memberName(dec)
|
name, err := memberName(dec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, 0, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if covered(name) {
|
handled, err := decodeCovered(name, dec, env)
|
||||||
// Повтор ключа metrics JSON допускает; секции ОБЪЕДИНЯЮТСЯ, а не
|
if err != nil {
|
||||||
// побеждает последняя: терять точки молча нельзя.
|
return err
|
||||||
var part []metricEnvelope
|
}
|
||||||
if err := dec.Decode(&part); err != nil {
|
if handled {
|
||||||
return nil, nil, 0, err
|
|
||||||
}
|
|
||||||
metrics = append(metrics, part...)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := swallow(dec); err != nil {
|
if err := swallow(dec); err != nil {
|
||||||
return nil, nil, 0, err
|
return err
|
||||||
}
|
}
|
||||||
if _, dup := seen[name]; dup {
|
if _, dup := seen[name]; dup {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[name] = struct{}{}
|
seen[name] = struct{}{}
|
||||||
if len(uncovered) >= maxUncovered {
|
if len(env.uncovered) >= maxUncovered {
|
||||||
dropped++
|
env.dropped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
uncovered = append(uncovered, clipSection(name))
|
env.uncovered = append(env.uncovered, clipSection(name))
|
||||||
}
|
}
|
||||||
if _, err := dec.Token(); err != nil { // закрывающая скобка data
|
if _, err := dec.Token(); err != nil { // закрывающая скобка data
|
||||||
return nil, nil, 0, err
|
return err
|
||||||
}
|
}
|
||||||
return metrics, uncovered, dropped, nil
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeSection читает секцию сущностей элементами исходных байтов.
|
||||||
|
//
|
||||||
|
// Разбор до `json.RawMessage`, а не до структуры: сущность хранится дословно, и
|
||||||
|
// декодирование в типизированное значение потеряло бы литерал — ровно то, от
|
||||||
|
// чего защищает `Point.Raw`.
|
||||||
|
func decodeSection(dec *json.Decoder) ([]json.RawMessage, error) {
|
||||||
|
var part []json.RawMessage
|
||||||
|
if err := dec.Decode(&part); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return part, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// memberName читает имя члена объекта. Token() отдаёт имя уже после разбора
|
// memberName читает имя члена объекта. Token() отдаёт имя уже после разбора
|
||||||
|
|||||||
+12
-11
@@ -548,10 +548,11 @@ func FuzzParse(f *testing.F) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Половина живого потока состоит из непокрытых секций целиком (48 доставок из
|
// Непокрытая секция неотличима от разобранной доставки с пустой секцией
|
||||||
// 99: workouts и stateOfMind). Без списка они неотличимы от разобранной
|
// метрик, если её не назвать: ретеншен, ориентируясь на статус, срезал бы
|
||||||
// доставки с пустой секцией метрик, и ретеншен, ориентируясь на статус, срезал
|
// тела, которые для секций без экспорта Apple единственный источник. С тех пор
|
||||||
// бы тела, которые для stateOfMind единственный источник.
|
// как workouts и stateOfMind стали покрытыми, роль непокрытой в фикстуре
|
||||||
|
// играют секции, которых поток ещё не приносил.
|
||||||
func TestParseПеречисляетНепокрытыеСекции(t *testing.T) {
|
func TestParseПеречисляетНепокрытыеСекции(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -560,7 +561,7 @@ func TestParseПеречисляетНепокрытыеСекции(t *testing.
|
|||||||
t.Fatalf("разбор: %v", err)
|
t.Fatalf("разбор: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
want := []string{"stateOfMind", "workouts"}
|
want := []string{"ecg", "symptoms"}
|
||||||
if !slices.Equal(res.Uncovered, want) {
|
if !slices.Equal(res.Uncovered, want) {
|
||||||
t.Errorf("непокрытые %v, ожидались %v", res.Uncovered, want)
|
t.Errorf("непокрытые %v, ожидались %v", res.Uncovered, want)
|
||||||
}
|
}
|
||||||
@@ -578,11 +579,11 @@ func TestParseНепокрытыеСекцииГраницыИДетермини
|
|||||||
t.Run("доставка из одной непокрытой секции", func(t *testing.T) {
|
t.Run("доставка из одной непокрытой секции", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
res, err := hae.Parse([]byte(`{"data":{"stateOfMind":[{"x":1}]}}`), hae.Meta{})
|
res, err := hae.Parse([]byte(`{"data":{"ecg":[{"x":1}]}}`), hae.Meta{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("разбор: %v", err)
|
t.Fatalf("разбор: %v", err)
|
||||||
}
|
}
|
||||||
if !slices.Equal(res.Uncovered, []string{"stateOfMind"}) {
|
if !slices.Equal(res.Uncovered, []string{"ecg"}) {
|
||||||
t.Errorf("непокрытые %v", res.Uncovered)
|
t.Errorf("непокрытые %v", res.Uncovered)
|
||||||
}
|
}
|
||||||
if len(res.Points) != 0 {
|
if len(res.Points) != 0 {
|
||||||
@@ -609,11 +610,11 @@ func TestParseНепокрытыеСекцииГраницыИДетермини
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
bodies := []string{
|
bodies := []string{
|
||||||
`{"data":{"workouts":[],"stateOfMind":[],"ecg":[]}}`,
|
`{"data":{"symptoms":[],"medications":[],"ecg":[]}}`,
|
||||||
`{"data":{"ecg":[],"workouts":[],"stateOfMind":[]}}`,
|
`{"data":{"ecg":[],"symptoms":[],"medications":[]}}`,
|
||||||
`{"data":{"stateOfMind":[],"ecg":[],"workouts":[],"ecg":[]}}`,
|
`{"data":{"medications":[],"ecg":[],"symptoms":[],"ecg":[]}}`,
|
||||||
}
|
}
|
||||||
want := []string{"ecg", "stateOfMind", "workouts"}
|
want := []string{"ecg", "medications", "symptoms"}
|
||||||
for _, b := range bodies {
|
for _, b := range bodies {
|
||||||
res, err := hae.Parse([]byte(b), hae.Meta{})
|
res, err := hae.Parse([]byte(b), hae.Meta{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -116,6 +116,13 @@ func TestParseУдержаниеКучиНепокрытойСекции(t *test
|
|||||||
t.Logf("непокрытых %v, удержано %d МиБ при теле %d МиБ",
|
t.Logf("непокрытых %v, удержано %d МиБ при теле %d МиБ",
|
||||||
res.Uncovered, retained>>20, len(body)>>20)
|
res.Uncovered, retained>>20, len(body)>>20)
|
||||||
|
|
||||||
|
// Замер обязан идти по ветке проглатывания. Без этой проверки расширение
|
||||||
|
// множества покрытых секций превращает сторож в зелёную пустышку — что уже
|
||||||
|
// однажды и произошло.
|
||||||
|
if len(res.Uncovered) == 0 {
|
||||||
|
t.Fatal("непокрытых секций нет — замер идёт мимо проглатывания и ничего не сторожит")
|
||||||
|
}
|
||||||
|
|
||||||
if retained > limit {
|
if retained > limit {
|
||||||
t.Errorf("удержано %d МиБ при теле %d МиБ — больше четырёх тел; "+
|
t.Errorf("удержано %d МиБ при теле %d МиБ — больше четырёх тел; "+
|
||||||
"похоже, секции удерживаются, а не проглатываются",
|
"похоже, секции удерживаются, а не проглатываются",
|
||||||
@@ -149,9 +156,13 @@ func bodyWithUncovered(size int) []byte {
|
|||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(`{"data":{"metrics":[{"name":"m","units":"count","data":[`)
|
b.WriteString(`{"data":{"metrics":[{"name":"m","units":"count","data":[`)
|
||||||
b.WriteString(`{"date":"2025-06-05 10:00:00 +0300","qty":1}`)
|
b.WriteString(`{"date":"2025-06-05 10:00:00 +0300","qty":1}`)
|
||||||
b.WriteString(`]}],"stateOfMind":[`)
|
// Секция обязана быть ЗАВЕДОМО НЕПОКРЫТОЙ: тело из покрытой секции идёт
|
||||||
|
// мимо проглатывания, и замер вырождается в ноль, оставаясь зелёным.
|
||||||
|
// Так уже случилось однажды: здесь стоял `stateOfMind`, и задача, покрывшая
|
||||||
|
// его разбором, обезоружила сторож молча.
|
||||||
|
b.WriteString(`]}],"ecg":[`)
|
||||||
|
|
||||||
const entry = `{"id":"00000000-0000-0000-0000-000000000000","kind":"momentary_emotion","valence":0.5},`
|
const entry = `{"id":"00000000-0000-0000-0000-000000000000","classification":"sinusRhythm"},`
|
||||||
for b.Len() < size {
|
for b.Len() < size {
|
||||||
b.WriteString(entry)
|
b.WriteString(entry)
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+21
@@ -12,6 +12,19 @@
|
|||||||
переставлены с сохранением формы), даты (сдвинуты на постоянную величину),
|
переставлены с сохранением формы), даты (сдвинуты на постоянную величину),
|
||||||
имена устройств.
|
имена устройств.
|
||||||
|
|
||||||
|
Для сущностей с собственным `id` вычищается дополнительно: сами
|
||||||
|
идентификаторы (псевдо-UUID той же формы), метки RFC 3339 и `route[].timestamp`
|
||||||
|
(сдвигаются, как и прочие даты), а также словарные значения `stateOfMind` —
|
||||||
|
`kind`, `valenceClassification`, `labels`, `associations`. Последнее не
|
||||||
|
перестраховка: это измерение душевного состояния, самое чувствительное, что
|
||||||
|
есть в потоке. Значения подменяются другими кодами из того же словаря HealthKit,
|
||||||
|
поэтому форма (snake_case, строка против массива строк) сохраняется, а смысл —
|
||||||
|
нет. Побочный эффект подмены: в `labels` могут появиться повторы, которых HAE не
|
||||||
|
шлёт; разбору это безразлично.
|
||||||
|
|
||||||
|
Координаты маршрута вычищаются как обычные числа — они не отличаются от прочих
|
||||||
|
измерений и после подмены указывают в никуда.
|
||||||
|
|
||||||
| Файл | Что проверяет |
|
| Файл | Что проверяет |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `minute.json` | минутная доставка; плотные метрики минутные, одна (`apple_stand_hour`) часовая — классификация **по метрике**, а не по доставке; редкие наследуют минутный слой; суточная сводка сна |
|
| `minute.json` | минутная доставка; плотные метрики минутные, одна (`apple_stand_hour`) часовая — классификация **по метрике**, а не по доставке; редкие наследуют минутный слой; суточная сводка сна |
|
||||||
@@ -20,7 +33,12 @@
|
|||||||
| `mixed.json` | одна доставка с минутными, посекундными и часовыми метриками — перенастройка автоматизации |
|
| `mixed.json` | одна доставка с минутными, посекундными и часовыми метриками — перенастройка автоматизации |
|
||||||
| `heartbeat_series.json` | точка с `heartbeatSeries`: третий формат времени, серия проходит исходными байтами |
|
| `heartbeat_series.json` | точка с `heartbeatSeries`: третий формат времени, серия проходит исходными байтами |
|
||||||
| `sparse_sleep.json` | доставка **без плотных метрик** (заголовок `Default` не спасает) и поэпизодный сон с задвоенной меткой — интервальная идентичность |
|
| `sparse_sleep.json` | доставка **без плотных метрик** (заголовок `Default` не спасает) и поэпизодный сон с задвоенной меткой — интервальная идентичность |
|
||||||
|
| `workout_route.json` | уличная тренировка с маршрутом и внутренними рядами; ряды урезаны `--limit`, форма точки маршрута сохранена |
|
||||||
|
| `workout_indoor.json` | тренировка без маршрута: набор полей зависит от типа (`temperature`, `humidity`, `intensity` вместо `route`, `avgSpeed`, `flightsClimbed`) |
|
||||||
|
| `state_of_mind.json` | `stateOfMind`: RFC 3339 в UTC, коды вместо переводов, поля `source` нет вовсе |
|
||||||
|
| `uncovered_sections.json` | одна доставка со всеми родами секций сразу — покрытыми и непокрытыми. Рукотворная: живой поток шлёт по одной секции за раз |
|
||||||
| `handmade_edge.json` | случаи, которых живой поток не даёт (см. ниже) |
|
| `handmade_edge.json` | случаи, которых живой поток не даёт (см. ниже) |
|
||||||
|
| `handmade_entities.json` | краевые случаи сущностей: пустой, отсутствующий и слишком длинный `id`; неразбираемая метка и её отсутствие; неразбираемый `end`; нечисловая и отсутствующая длительность; две версии одного `id` в одном теле; элемент, не являющийся объектом |
|
||||||
|
|
||||||
`handmade_edge.json` собран руками, скриптом не воспроизводится:
|
`handmade_edge.json` собран руками, скриптом не воспроизводится:
|
||||||
|
|
||||||
@@ -43,3 +61,6 @@ python3 tmp/research/fixtures.py --list
|
|||||||
python3 tmp/research/fixtures.py <файл из data/raw> --metrics a,b --limit 14 \
|
python3 tmp/research/fixtures.py <файл из data/raw> --metrics a,b --limit 14 \
|
||||||
--out internal/hae/testdata/<имя>.json
|
--out internal/hae/testdata/<имя>.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`--limit` режет и внутренние ряды тренировки (маршрут, пульс, энергия): фикстуре
|
||||||
|
нужна форма точки ряда, а не 593 её экземпляра.
|
||||||
|
|||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Рукотворная фикстура: краевые случаи сущностей с собственным id, которых живой поток не даёт. Скриптом tmp/research/fixtures.py не порождается, значения выдуманы целиком.",
|
||||||
|
"data": {
|
||||||
|
"workouts": [
|
||||||
|
{
|
||||||
|
"id": "",
|
||||||
|
"name": "Пустой идентификатор",
|
||||||
|
"start": "2025-06-05 10:00:00 +0300",
|
||||||
|
"end": "2025-06-05 10:10:00 +0300",
|
||||||
|
"duration": 600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Идентификатора нет вовсе",
|
||||||
|
"start": "2025-06-05 10:00:00 +0300",
|
||||||
|
"end": "2025-06-05 10:10:00 +0300"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-4000-8000-000000000001",
|
||||||
|
"name": "Метка не разбирается",
|
||||||
|
"start": "вчера вечером",
|
||||||
|
"end": "2025-06-05 10:10:00 +0300"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-4000-8000-000000000002",
|
||||||
|
"name": "Метки нет вовсе",
|
||||||
|
"duration": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-4000-8000-000000000003",
|
||||||
|
"name": "Конец не разбирается",
|
||||||
|
"start": "2025-06-05 11:00:00 +0300",
|
||||||
|
"end": "никогда",
|
||||||
|
"duration": 61.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-4000-8000-000000000004",
|
||||||
|
"name": "Длительность не число",
|
||||||
|
"start": "2025-06-05 12:00:00 +0300",
|
||||||
|
"end": "2025-06-05 12:01:00 +0300",
|
||||||
|
"duration": "минута"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-4000-8000-000000000005",
|
||||||
|
"name": "Длительности нет",
|
||||||
|
"start": "2025-06-05 13:00:00 +0300",
|
||||||
|
"end": "2025-06-05 13:01:00 +0300"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-4000-8000-000000000006",
|
||||||
|
"name": "Начало берётся из date",
|
||||||
|
"date": "2025-06-05 14:00:00 +0300",
|
||||||
|
"duration": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-4000-8000-000000000007",
|
||||||
|
"name": "Две версии в одном теле, первая",
|
||||||
|
"start": "2025-06-05 15:00:00 +0300",
|
||||||
|
"end": "2025-06-05 15:30:00 +0300",
|
||||||
|
"duration": 1800,
|
||||||
|
"totalEnergy": {"qty": 100.5, "units": "kJ"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-4000-8000-000000000007",
|
||||||
|
"name": "Две версии в одном теле, вторая",
|
||||||
|
"start": "2025-06-05 15:00:00 +0300",
|
||||||
|
"end": "2025-06-05 15:30:00 +0300",
|
||||||
|
"duration": 1800,
|
||||||
|
"totalEnergy": {"qty": 110.5, "units": "kJ"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-4000-8000-000000000008-и-ещё-очень-длинный-хвост-который-заведомо-выходит-за-предел-длины-идентификатора-принятый-разбором-сущностей",
|
||||||
|
"name": "Идентификатор длиннее предела",
|
||||||
|
"start": "2025-06-05 16:00:00 +0300",
|
||||||
|
"end": "2025-06-05 16:01:00 +0300"
|
||||||
|
},
|
||||||
|
"не объект вовсе",
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-4000-8000-000000000009",
|
||||||
|
"name": "Незнакомое поле и дословные литералы",
|
||||||
|
"start": "2025-06-05 17:00:00 +0300",
|
||||||
|
"end": "2025-06-05 17:05:00 +0300",
|
||||||
|
"duration": 300,
|
||||||
|
"невиданноеПоле": {"вложенное": [1.0, 9007199254740993, 0.123456789012345678]},
|
||||||
|
"isIndoor": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stateOfMind": [
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-4000-8000-00000000000a",
|
||||||
|
"kind": "momentary_emotion",
|
||||||
|
"start": "2025-06-05T18:00:00Z",
|
||||||
|
"end": "2025-06-05T18:00:00Z",
|
||||||
|
"valence": 0.5,
|
||||||
|
"valenceClassification": "pleasant",
|
||||||
|
"labels": ["calm"],
|
||||||
|
"associations": ["hobbies"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-4000-8000-00000000000b",
|
||||||
|
"kind": "daily_mood",
|
||||||
|
"start": "2025-06-05 21:00:00 +0300",
|
||||||
|
"end": "2025-06-06 21:00:00 +0300",
|
||||||
|
"valence": -0.25,
|
||||||
|
"valenceClassification": "slightly_unpleasant",
|
||||||
|
"labels": [],
|
||||||
|
"associations": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"stateOfMind": [
|
||||||
|
{
|
||||||
|
"associations": [
|
||||||
|
"fitness"
|
||||||
|
],
|
||||||
|
"valenceClassification": "slightly_unpleasant",
|
||||||
|
"valence": 0.021376147736425923,
|
||||||
|
"end": "2025-06-05T18:03:51Z",
|
||||||
|
"labels": [
|
||||||
|
"peaceful",
|
||||||
|
"peaceful"
|
||||||
|
],
|
||||||
|
"kind": "momentary_emotion",
|
||||||
|
"id": "50244944-3582-4508-7804-525425479700",
|
||||||
|
"start": "2025-06-05T18:03:51Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "daily_mood",
|
||||||
|
"end": "2025-06-05T18:03:17Z",
|
||||||
|
"id": "87868435-7832-4736-7633-078876535422",
|
||||||
|
"associations": [
|
||||||
|
"hobbies"
|
||||||
|
],
|
||||||
|
"start": "2025-06-05T18:03:17Z",
|
||||||
|
"valence": 0.12057562819279189,
|
||||||
|
"valenceClassification": "slightly_unpleasant",
|
||||||
|
"labels": [
|
||||||
|
"relieved",
|
||||||
|
"relieved"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+90
-15
@@ -5,16 +5,56 @@
|
|||||||
"name": "step_count",
|
"name": "step_count",
|
||||||
"units": "count",
|
"units": "count",
|
||||||
"data": [
|
"data": [
|
||||||
{"date": "2025-06-05 09:00:00 +0300", "qty": 41.0, "source": "Device A"},
|
{
|
||||||
{"date": "2025-06-05 09:01:00 +0300", "qty": 17.0, "source": "Device A"},
|
"date": "2025-06-05 09:00:00 +0300",
|
||||||
{"date": "2025-06-05 09:02:00 +0300", "qty": 82.0, "source": "Device A"},
|
"qty": 41.0,
|
||||||
{"date": "2025-06-05 09:03:00 +0300", "qty": 5.0, "source": "Device A"},
|
"source": "Device A"
|
||||||
{"date": "2025-06-05 09:04:00 +0300", "qty": 63.0, "source": "Device A"},
|
},
|
||||||
{"date": "2025-06-05 09:05:00 +0300", "qty": 28.0, "source": "Device A"},
|
{
|
||||||
{"date": "2025-06-05 09:06:00 +0300", "qty": 94.0, "source": "Device A"},
|
"date": "2025-06-05 09:01:00 +0300",
|
||||||
{"date": "2025-06-05 09:07:00 +0300", "qty": 12.0, "source": "Device A"},
|
"qty": 17.0,
|
||||||
{"date": "2025-06-05 09:08:00 +0300", "qty": 71.0, "source": "Device A"},
|
"source": "Device A"
|
||||||
{"date": "2025-06-05 09:09:00 +0300", "qty": 36.0, "source": "Device A"}
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-06-05 09:02:00 +0300",
|
||||||
|
"qty": 82.0,
|
||||||
|
"source": "Device A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-06-05 09:03:00 +0300",
|
||||||
|
"qty": 5.0,
|
||||||
|
"source": "Device A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-06-05 09:04:00 +0300",
|
||||||
|
"qty": 63.0,
|
||||||
|
"source": "Device A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-06-05 09:05:00 +0300",
|
||||||
|
"qty": 28.0,
|
||||||
|
"source": "Device A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-06-05 09:06:00 +0300",
|
||||||
|
"qty": 94.0,
|
||||||
|
"source": "Device A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-06-05 09:07:00 +0300",
|
||||||
|
"qty": 12.0,
|
||||||
|
"source": "Device A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-06-05 09:08:00 +0300",
|
||||||
|
"qty": 71.0,
|
||||||
|
"source": "Device A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-06-05 09:09:00 +0300",
|
||||||
|
"qty": 36.0,
|
||||||
|
"source": "Device A"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -26,11 +66,28 @@
|
|||||||
"end": "2025-06-05 08:30:00 +0300",
|
"end": "2025-06-05 08:30:00 +0300",
|
||||||
"duration": 1800,
|
"duration": 1800,
|
||||||
"heartRateData": [
|
"heartRateData": [
|
||||||
{"date": "2025-06-05 08:00:07 +0300", "Min": 91.0, "Avg": 94.5, "Max": 98.0, "units": "count/min"},
|
{
|
||||||
{"date": "2025-06-05 08:00:21 +0300", "Min": 93.0, "Avg": 95.5, "Max": 99.0, "units": "count/min"}
|
"date": "2025-06-05 08:00:07 +0300",
|
||||||
|
"Min": 91.0,
|
||||||
|
"Avg": 94.5,
|
||||||
|
"Max": 98.0,
|
||||||
|
"units": "count/min"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-06-05 08:00:21 +0300",
|
||||||
|
"Min": 93.0,
|
||||||
|
"Avg": 95.5,
|
||||||
|
"Max": 99.0,
|
||||||
|
"units": "count/min"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"route": [
|
"route": [
|
||||||
{"lat": 10.0, "lon": 20.0, "altitude": 30.0, "timestamp": "2025-06-05 08:00:07 +0300"}
|
{
|
||||||
|
"lat": 10.0,
|
||||||
|
"lon": 20.0,
|
||||||
|
"altitude": 30.0,
|
||||||
|
"timestamp": "2025-06-05 08:00:07 +0300"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -41,9 +98,27 @@
|
|||||||
"end": "2025-06-05T05:12:33Z",
|
"end": "2025-06-05T05:12:33Z",
|
||||||
"kind": "momentary_emotion",
|
"kind": "momentary_emotion",
|
||||||
"valence": 0.25,
|
"valence": 0.25,
|
||||||
"labels": ["slightly_pleasant"],
|
"labels": [
|
||||||
|
"slightly_pleasant"
|
||||||
|
],
|
||||||
"associations": []
|
"associations": []
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"ecg": [
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-0000-0000-000000000003",
|
||||||
|
"start": "2025-06-05 07:00:00 +0300",
|
||||||
|
"classification": "sinusRhythm"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"symptoms": [
|
||||||
|
{
|
||||||
|
"id": "00000000-0000-0000-0000-000000000004",
|
||||||
|
"start": "2025-06-05 06:00:00 +0300",
|
||||||
|
"name": "Головная боль",
|
||||||
|
"severity": "mild"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"_comment": "Рукотворная фикстура: одна доставка со всеми родами секций сразу — покрытыми (metrics, workouts, stateOfMind) и непокрытыми (ecg, symptoms). Живой поток такого не даёт: автоматизация HAE шлёт одну секцию за раз."
|
||||||
}
|
}
|
||||||
|
|||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"workouts": [
|
||||||
|
{
|
||||||
|
"id": "19336133-5198-0770-0219-503343909539",
|
||||||
|
"maxHeartRate": {
|
||||||
|
"qty": 199,
|
||||||
|
"units": "count/min"
|
||||||
|
},
|
||||||
|
"temperature": {
|
||||||
|
"units": "degC",
|
||||||
|
"qty": 25.642900109344569
|
||||||
|
},
|
||||||
|
"name": "В помещении Ходьба",
|
||||||
|
"avgHeartRate": {
|
||||||
|
"qty": 47.271142352144859,
|
||||||
|
"units": "count/min"
|
||||||
|
},
|
||||||
|
"isIndoor": true,
|
||||||
|
"duration": 11.104378534563007,
|
||||||
|
"walkingAndRunningDistance": [
|
||||||
|
{
|
||||||
|
"qty": 0.051178653222924688,
|
||||||
|
"date": "2025-06-05 21:07:25 +0300",
|
||||||
|
"units": "km",
|
||||||
|
"source": "Device A "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"qty": 0.0022744856624713684,
|
||||||
|
"date": "2025-06-05 21:08:25 +0300",
|
||||||
|
"units": "km",
|
||||||
|
"source": "Device A "
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"intensity": {
|
||||||
|
"qty": 1.1101433306047975,
|
||||||
|
"units": "kcal/hr·kg"
|
||||||
|
},
|
||||||
|
"start": "2025-06-05 21:07:25 +0300",
|
||||||
|
"end": "2025-06-05 21:08:56 +0300",
|
||||||
|
"activeEnergy": [
|
||||||
|
{
|
||||||
|
"units": "kJ",
|
||||||
|
"date": "2025-06-05 21:07:25 +0300",
|
||||||
|
"qty": 49.484435766191329,
|
||||||
|
"source": "Device A "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2025-06-05 21:08:25 +0300",
|
||||||
|
"qty": 1.2893292371894432,
|
||||||
|
"units": "kJ",
|
||||||
|
"source": "Device A "
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"basalEnergy": [
|
||||||
|
{
|
||||||
|
"units": "kJ",
|
||||||
|
"qty": 8.118214886809112,
|
||||||
|
"date": "2025-06-05 21:07:25 +0300",
|
||||||
|
"source": "Device A "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"units": "kJ",
|
||||||
|
"date": "2025-06-05 21:08:25 +0300",
|
||||||
|
"source": "Device A ",
|
||||||
|
"qty": 8.3170473912094565
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"location": "В помещении",
|
||||||
|
"metadata": {},
|
||||||
|
"distance": {
|
||||||
|
"units": "km",
|
||||||
|
"qty": 0.097916506499049191
|
||||||
|
},
|
||||||
|
"totalEnergy": {
|
||||||
|
"qty": 66.474914481955616,
|
||||||
|
"units": "kJ"
|
||||||
|
},
|
||||||
|
"activeEnergyBurned": {
|
||||||
|
"units": "kJ",
|
||||||
|
"qty": 21.260499065517783
|
||||||
|
},
|
||||||
|
"speed": {
|
||||||
|
"qty": 3.842565481147650,
|
||||||
|
"units": "km/hr"
|
||||||
|
},
|
||||||
|
"heartRateData": [
|
||||||
|
{
|
||||||
|
"Max": 199,
|
||||||
|
"date": "2025-06-05 21:07:25 +0300",
|
||||||
|
"Avg": 86.126023892474565,
|
||||||
|
"source": "Device A ",
|
||||||
|
"Min": 68,
|
||||||
|
"units": "count/min"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Min": 62,
|
||||||
|
"source": "Device A ",
|
||||||
|
"date": "2025-06-05 21:08:25 +0300",
|
||||||
|
"Avg": 42.420241306988357,
|
||||||
|
"Max": 11,
|
||||||
|
"units": "count/min"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"heartRateRecovery": [
|
||||||
|
{
|
||||||
|
"Avg": 18,
|
||||||
|
"units": "count/min",
|
||||||
|
"Max": 18,
|
||||||
|
"date": "2025-06-05 21:09:01 +0300",
|
||||||
|
"Min": 18,
|
||||||
|
"source": "Device A "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Min": 18,
|
||||||
|
"date": "2025-06-05 21:09:05 +0300",
|
||||||
|
"Avg": 18,
|
||||||
|
"Max": 18,
|
||||||
|
"units": "count/min",
|
||||||
|
"source": "Device A "
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Max": 18,
|
||||||
|
"date": "2025-06-05 21:09:07 +0300",
|
||||||
|
"Avg": 18,
|
||||||
|
"source": "Device A ",
|
||||||
|
"Min": 18,
|
||||||
|
"units": "count/min"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Max": 64,
|
||||||
|
"Avg": 64,
|
||||||
|
"Min": 64,
|
||||||
|
"source": "Device A |Device B",
|
||||||
|
"date": "2025-06-05 21:10:44 +0300",
|
||||||
|
"units": "count/min"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Avg": 84,
|
||||||
|
"Max": 84,
|
||||||
|
"units": "count/min",
|
||||||
|
"Min": 84,
|
||||||
|
"source": "Device A |Device B",
|
||||||
|
"date": "2025-06-05 21:10:49 +0300"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "Device A |Device B",
|
||||||
|
"Min": 16,
|
||||||
|
"units": "count/min",
|
||||||
|
"Max": 16,
|
||||||
|
"Avg": 16,
|
||||||
|
"date": "2025-06-05 21:10:54 +0300"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"humidity": {
|
||||||
|
"units": "%",
|
||||||
|
"qty": 49
|
||||||
|
},
|
||||||
|
"heartRate": {
|
||||||
|
"max": {
|
||||||
|
"qty": 199,
|
||||||
|
"units": "count/min"
|
||||||
|
},
|
||||||
|
"avg": {
|
||||||
|
"qty": 47.271142352144859,
|
||||||
|
"units": "count/min"
|
||||||
|
},
|
||||||
|
"min": {
|
||||||
|
"qty": 68,
|
||||||
|
"units": "count/min"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+588
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,12 +83,21 @@ func TestReplayЖивогоАрхива(t *testing.T) {
|
|||||||
t.Fatal("ни одна доставка не свернулась")
|
t.Fatal("ни одна доставка не свернулась")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Половина живого потока не несёт metrics вовсе (находка 50): такие
|
// Половина живого потока не несёт metrics вовсе (находка 50): это
|
||||||
// доставки обязаны быть отличимы от разобранных целиком, иначе ретеншен
|
// тренировки и состояние разума. С тех пор как обе секции покрыты,
|
||||||
// срежет тела, которые для stateOfMind единственный источник.
|
// частично разобранных доставок в архиве нет — и вместо них проверяется то,
|
||||||
if first.Partial == 0 {
|
// ради чего покрытие делалось: сущности доехали до витрины.
|
||||||
t.Error("ни одной частично разобранной доставки — перечисление непокрытых секций не работает")
|
//
|
||||||
|
// Проверяется свойство, а не число: корпус растёт с каждой доставкой, а
|
||||||
|
// прогон живого архива в гейт не входит, так что протухшая константа
|
||||||
|
// покраснела бы молча.
|
||||||
|
if first.Workouts == 0 {
|
||||||
|
t.Error("тренировок в витрине нет — секция workouts не разбирается")
|
||||||
}
|
}
|
||||||
|
if first.Records == 0 {
|
||||||
|
t.Error("записей в витрине нет — секция stateOfMind не разбирается")
|
||||||
|
}
|
||||||
|
t.Logf("тренировок %d, записей %d", first.Workouts, first.Records)
|
||||||
|
|
||||||
// Повторное проигрывание того же журнала даёт то же состояние: свёртка
|
// Повторное проигрывание того же журнала даёт то же состояние: свёртка
|
||||||
// детерминирована, и пересборка даёт то же, что живой приём.
|
// детерминирована, и пересборка даёт то же, что живой приём.
|
||||||
@@ -101,6 +110,10 @@ func TestReplayЖивогоАрхива(t *testing.T) {
|
|||||||
if second.Buckets != first.Buckets {
|
if second.Buckets != first.Buckets {
|
||||||
t.Errorf("повторное проигрывание изменило число объектов: %d → %d", first.Buckets, second.Buckets)
|
t.Errorf("повторное проигрывание изменило число объектов: %d → %d", first.Buckets, second.Buckets)
|
||||||
}
|
}
|
||||||
|
if second.Workouts != first.Workouts || second.Records != first.Records {
|
||||||
|
t.Errorf("повторное проигрывание изменило число сущностей: %d/%d → %d/%d",
|
||||||
|
first.Workouts, first.Records, second.Workouts, second.Records)
|
||||||
|
}
|
||||||
if second.Fingerprint != first.Fingerprint {
|
if second.Fingerprint != first.Fingerprint {
|
||||||
t.Errorf("повторное проигрывание изменило содержимое объектов:\n %s\n %s",
|
t.Errorf("повторное проигрывание изменило содержимое объектов:\n %s\n %s",
|
||||||
first.Fingerprint, second.Fingerprint)
|
first.Fingerprint, second.Fingerprint)
|
||||||
|
|||||||
@@ -75,7 +75,12 @@ type Report struct {
|
|||||||
// исходов разошлись бы при первой же правке классификации.
|
// исходов разошлись бы при первой же правке классификации.
|
||||||
Outcome
|
Outcome
|
||||||
|
|
||||||
Buckets int64
|
Buckets int64
|
||||||
|
// Workouts и Records — остальные единицы хранения витрины. Считаются рядом
|
||||||
|
// с объектами потому, что отпечаток отвечает «да/нет» за витрину целиком, а
|
||||||
|
// решение о подмене базы необратимо и требует направления расхождения.
|
||||||
|
Workouts int64
|
||||||
|
Records int64
|
||||||
Fingerprint string
|
Fingerprint string
|
||||||
|
|
||||||
// Canceled — проигрывание прервано отменой, а не дошло до конца.
|
// Canceled — проигрывание прервано отменой, а не дошло до конца.
|
||||||
@@ -171,6 +176,14 @@ func Run(ctx context.Context, o Options) (Report, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return stopOr(rep, err)
|
return stopOr(rep, err)
|
||||||
}
|
}
|
||||||
|
rep.Workouts, err = o.Target.CountWorkouts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return stopOr(rep, err)
|
||||||
|
}
|
||||||
|
rep.Records, err = o.Target.CountRecords(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return stopOr(rep, err)
|
||||||
|
}
|
||||||
rep.Fingerprint, err = o.Target.Fingerprint(ctx)
|
rep.Fingerprint, err = o.Target.Fingerprint(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return stopOr(rep, err)
|
return stopOr(rep, err)
|
||||||
@@ -190,7 +203,9 @@ func Run(ctx context.Context, o Options) (Report, error) {
|
|||||||
"failed_other", rep.FailedOther,
|
"failed_other", rep.FailedOther,
|
||||||
"partial", rep.Partial,
|
"partial", rep.Partial,
|
||||||
"incomparable", rep.Incomparable,
|
"incomparable", rep.Incomparable,
|
||||||
"buckets", rep.Buckets)
|
"buckets", rep.Buckets,
|
||||||
|
"workouts", rep.Workouts,
|
||||||
|
"records", rep.Records)
|
||||||
return rep, nil
|
return rep, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+173
-24
@@ -72,6 +72,21 @@ type MergeStats struct {
|
|||||||
Collisions []Collision
|
Collisions []Collision
|
||||||
// IncomparableAt — то же для несравнимых наборов.
|
// IncomparableAt — то же для несравнимых наборов.
|
||||||
IncomparableAt []Collision
|
IncomparableAt []Collision
|
||||||
|
|
||||||
|
// Workouts и Records — сколько сущностей пришло с доставкой.
|
||||||
|
Workouts int
|
||||||
|
Records int
|
||||||
|
// WorkoutsWritten и RecordsWritten — сколько из них действительно легло в
|
||||||
|
// витрину. Разница с пришедшими — работа хеша-детектора: тренировка
|
||||||
|
// переприсылается каждой доставкой, пока не доедет маршрут.
|
||||||
|
WorkoutsWritten int
|
||||||
|
RecordsWritten int
|
||||||
|
// EntitiesHeld — приехавшие версии, отклонённые как теряющие содержание
|
||||||
|
// сохранённой (включая несравнимые наборы). Это и есть плата за отказ
|
||||||
|
// объединять поля: событие считается, а не предотвращается молча.
|
||||||
|
EntitiesHeld int
|
||||||
|
// HeldAt — координаты первых таких сущностей, для записи в лог.
|
||||||
|
HeldAt []EntityRef
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collision — координаты объекта, где столкновение разрешилось перезаписью
|
// Collision — координаты объекта, где столкновение разрешилось перезаписью
|
||||||
@@ -102,35 +117,58 @@ func clipMetric(metric string) string {
|
|||||||
return metric[:maxMetricInLog] + "…"
|
return metric[:maxMetricInLog] + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MergePoints раскладывает точки по часовым объектам и сливает их с
|
// Merge раскладывает всё, что дала доставка, по витрине: точки — по часовым
|
||||||
// сохранёнными.
|
// объектам, сущности — по своим таблицам.
|
||||||
//
|
//
|
||||||
// Час берётся по НАЧАЛУ точки: интервал пересекает границы часов, и любой
|
// Час берётся по НАЧАЛУ точки: интервал пересекает границы часов, и любой
|
||||||
// другой выбор сделал бы принадлежность объекту зависящей от длительности.
|
// другой выбор сделал бы принадлежность объекту зависящей от длительности.
|
||||||
// Вся доставка сворачивается ОДНОЙ транзакцией, а не транзакцией на объект.
|
// Вся доставка сворачивается ОДНОЙ транзакцией, а не транзакцией на объект и
|
||||||
|
// не отдельной транзакцией на сущности.
|
||||||
//
|
//
|
||||||
// Транзакция на объект давала недетерминированное частичное состояние: обход
|
// Транзакция на объект давала недетерминированное частичное состояние: обход
|
||||||
// карты групп рандомизирован, и при отказе посреди доставки набор уже
|
// карты групп рандомизирован, и при отказе посреди доставки набор уже
|
||||||
// закоммиченных объектов каждый раз другой (измерено: восемь прогонов одной
|
// закоммиченных объектов каждый раз другой (измерено: восемь прогонов одной
|
||||||
// доставки — семь разных состояний). Это ломает инвариант «состояние
|
// доставки — семь разных состояний). Это ломает инвариант «состояние
|
||||||
// пересобираемо»: пересборка из архива давала бы не то, что живой приём, а
|
// пересобираемо»: пересборка из архива давала бы не то, что живой приём, а
|
||||||
// хеш-детектор после расхождения переписывал бы «неизменившееся».
|
// хеш-детектор после расхождения переписывал бы «неизменившееся». Наблюдение
|
||||||
|
// «секции не смешиваются в одной доставке» собрано за двое суток и основанием
|
||||||
|
// для второй транзакции не является.
|
||||||
//
|
//
|
||||||
// Заодно снимается стоимость: отдельный коммит на объект стоил около 0.7 мс,
|
// Заодно снимается стоимость: отдельный коммит на объект стоил около 0.7 мс,
|
||||||
// то есть 8.5 мс на килобайт тела.
|
// то есть 8.5 мс на килобайт тела.
|
||||||
func (s *Store) MergePoints(ctx context.Context, in []IncomingPoint, deliveryID string) (MergeStats, error) {
|
func (s *Store) Merge(ctx context.Context, in Incoming, from DeliveryRef) (MergeStats, error) {
|
||||||
groups := groupByHour(in)
|
groups := groupByHour(in.Points)
|
||||||
keys := sortedKeys(groups)
|
keys := sortedKeys(groups)
|
||||||
|
|
||||||
|
// Хеш и каноническая форма сущности считаются ОДИН раз на доставку, до
|
||||||
|
// входа в транзакцию: канонизация материализует значение целиком, а
|
||||||
|
// транзакция повторяется до пяти раз при занятости базы — внутри неё пик
|
||||||
|
// кучи умножился бы на число попыток.
|
||||||
|
workouts, err := prepareEntities(in.Workouts, from)
|
||||||
|
if err != nil {
|
||||||
|
return MergeStats{}, err
|
||||||
|
}
|
||||||
|
records, err := prepareEntities(in.Records, from)
|
||||||
|
if err != nil {
|
||||||
|
return MergeStats{}, err
|
||||||
|
}
|
||||||
|
workouts, workoutsHeld, workoutsHeldAt := dedupeEntities(workouts)
|
||||||
|
records, recordsHeld, recordsHeldAt := dedupeEntities(records)
|
||||||
|
|
||||||
var stats MergeStats
|
var stats MergeStats
|
||||||
err := s.inTx(ctx, func(tx *sql.Tx) error {
|
err = s.inTx(ctx, func(tx *sql.Tx) error {
|
||||||
// Счётчики обнуляются на каждой попытке: повтор транзакции начинает
|
// Счётчики обнуляются на каждой попытке: повтор транзакции начинает
|
||||||
// слияние заново, и накопленное от прошлой попытки посчиталось бы дважды.
|
// слияние заново, и накопленное от прошлой попытки посчиталось бы дважды.
|
||||||
stats = MergeStats{}
|
stats = MergeStats{
|
||||||
|
Workouts: len(in.Workouts),
|
||||||
|
Records: len(in.Records),
|
||||||
|
EntitiesHeld: workoutsHeld + recordsHeld,
|
||||||
|
HeldAt: clipRefs(append(append([]EntityRef{}, workoutsHeldAt...), recordsHeldAt...)),
|
||||||
|
}
|
||||||
|
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
group := groups[key]
|
group := groups[key]
|
||||||
res, err := mergeBucket(ctx, tx, key, group, deliveryID)
|
res, err := mergeBucket(ctx, tx, key, group, from.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -158,6 +196,23 @@ func (s *Store) MergePoints(ctx context.Context, in []IncomingPoint, deliveryID
|
|||||||
stats.IncomparableAt = append(stats.IncomparableAt, coord)
|
stats.IncomparableAt = append(stats.IncomparableAt, coord)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
now := Now()
|
||||||
|
written, held, heldAt, err := mergeEntities(ctx, tx, workoutTable, workouts, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stats.WorkoutsWritten = written
|
||||||
|
stats.EntitiesHeld += held
|
||||||
|
stats.HeldAt = clipRefs(append(stats.HeldAt, heldAt...))
|
||||||
|
|
||||||
|
written, held, heldAt, err = mergeEntities(ctx, tx, recordTable, records, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stats.RecordsWritten = written
|
||||||
|
stats.EntitiesHeld += held
|
||||||
|
stats.HeldAt = clipRefs(append(stats.HeldAt, heldAt...))
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -591,27 +646,44 @@ func encodePayload(points []Point) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("сериализация точек: %w", err)
|
return nil, fmt.Errorf("сериализация точек: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return gzipBytes(raw.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
// gzipBytes и gunzipBytes — единственное кодирование содержимого витрины,
|
||||||
|
// общее для часового объекта и для сущности с собственным `id`. Второй кадр
|
||||||
|
// упаковки разошёлся бы с первым при первой же правке — например, забытым
|
||||||
|
// Close, который оставляет усечённый блоб.
|
||||||
|
func gzipBytes(raw []byte) ([]byte, error) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
gz := gzip.NewWriter(&buf)
|
gz := gzip.NewWriter(&buf)
|
||||||
if _, err := gz.Write(raw.Bytes()); err != nil {
|
if _, err := gz.Write(raw); err != nil {
|
||||||
return nil, fmt.Errorf("сжатие точек: %w", err)
|
return nil, fmt.Errorf("сжатие содержимого: %w", err)
|
||||||
}
|
}
|
||||||
|
// Close дописывает хвост gzip; без него блоб читается лишь частично.
|
||||||
if err := gz.Close(); err != nil {
|
if err := gz.Close(); err != nil {
|
||||||
return nil, fmt.Errorf("закрытие gzip: %w", err)
|
return nil, fmt.Errorf("закрытие gzip: %w", err)
|
||||||
}
|
}
|
||||||
return buf.Bytes(), nil
|
return buf.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodePayload(payload []byte) ([]Point, error) {
|
func gunzipBytes(payload []byte) ([]byte, error) {
|
||||||
gz, err := gzip.NewReader(bytes.NewReader(payload))
|
gz, err := gzip.NewReader(bytes.NewReader(payload))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("распаковка точек: %w", err)
|
return nil, fmt.Errorf("распаковка содержимого: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = gz.Close() }()
|
defer func() { _ = gz.Close() }()
|
||||||
|
|
||||||
raw, err := io.ReadAll(gz)
|
raw, err := io.ReadAll(gz)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("чтение точек: %w", err)
|
return nil, fmt.Errorf("чтение содержимого: %w", err)
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodePayload(payload []byte) ([]Point, error) {
|
||||||
|
raw, err := gunzipBytes(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var sp []storedPoint
|
var sp []storedPoint
|
||||||
@@ -700,7 +772,7 @@ func (s *Store) CountBuckets(ctx context.Context) (int64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fingerprint возвращает отпечаток содержимого витрины: SHA-256 по координатам
|
// Fingerprint возвращает отпечаток содержимого витрины: SHA-256 по координатам
|
||||||
// и хешам всех объектов в детерминированном порядке.
|
// и хешам всех её сущностей в детерминированном порядке.
|
||||||
//
|
//
|
||||||
// Нужен проверке сходимости на живом архиве. Число объектов и число точек к
|
// Нужен проверке сходимости на живом архиве. Число объектов и число точек к
|
||||||
// правилу разрешения столкновений нечувствительны: на координате всегда лежит
|
// правилу разрешения столкновений нечувствительны: на координате всегда лежит
|
||||||
@@ -708,25 +780,58 @@ func (s *Store) CountBuckets(ctx context.Context) (int64, error) {
|
|||||||
// Значит «объектов столько же» совпадёт и при заведомо сломанном правиле, а
|
// Значит «объектов столько же» совпадёт и при заведомо сломанном правиле, а
|
||||||
// отпечаток — нет.
|
// отпечаток — нет.
|
||||||
//
|
//
|
||||||
// Значений точек он не раскрывает: содержимое участвует только своим хешем.
|
// Покрывает ВСЕ единицы хранения — часовые объекты, тренировки и записи.
|
||||||
|
// Отпечаток одних объектов давал бы «состояние сошлось» при разъехавшихся
|
||||||
|
// тренировках, то есть ломался бы молча тем самым изменением, которое добавило
|
||||||
|
// данные.
|
||||||
|
//
|
||||||
|
// Все разделы читаются ОДНИМ снимком базы: отпечаток рабочей витрины снимается
|
||||||
|
// под живым приёмом, и запросы вне общей транзакции дали бы смесь «объекты до»
|
||||||
|
// и «тренировки после» — ложное расхождение у единственного оракула.
|
||||||
|
//
|
||||||
|
// Значений он не раскрывает: содержимое участвует только своим хешем.
|
||||||
func (s *Store) Fingerprint(ctx context.Context) (string, error) {
|
func (s *Store) Fingerprint(ctx context.Context) (string, error) {
|
||||||
|
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("begin read tx: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
h := sha256.New()
|
||||||
|
if err := fingerprintBuckets(ctx, tx, h); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := fingerprintEntities(ctx, tx, h); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Признак раздела впереди строки: без него строка одного раздела может совпасть
|
||||||
|
// со строкой другого, и два разных состояния витрины дали бы один отпечаток.
|
||||||
|
const (
|
||||||
|
fpBucket = "b"
|
||||||
|
fpWorkout = "w"
|
||||||
|
fpRecord = "r"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fingerprintBuckets(ctx context.Context, tx *sql.Tx, h io.Writer) error {
|
||||||
const q = `
|
const q = `
|
||||||
SELECT metric, layer, hour_utc, content_hash, points, units, sealed FROM bucket
|
SELECT metric, layer, hour_utc, content_hash, points, units, sealed FROM bucket
|
||||||
ORDER BY metric, layer, hour_utc`
|
ORDER BY metric, layer, hour_utc`
|
||||||
|
|
||||||
rows, err := s.db.QueryContext(ctx, q)
|
rows, err := tx.QueryContext(ctx, q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("select buckets: %w", err)
|
return fmt.Errorf("select buckets: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = rows.Close() }()
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
h := sha256.New()
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var metric, layer, hour, hash, units string
|
var metric, layer, hour, hash, units string
|
||||||
var points int
|
var points int
|
||||||
var sealed bool
|
var sealed bool
|
||||||
if err := rows.Scan(&metric, &layer, &hour, &hash, &points, &units, &sealed); err != nil {
|
if err := rows.Scan(&metric, &layer, &hour, &hash, &points, &units, &sealed); err != nil {
|
||||||
return "", fmt.Errorf("scan bucket: %w", err)
|
return fmt.Errorf("scan bucket: %w", err)
|
||||||
}
|
}
|
||||||
// Поля переменной длины идут с длиной впереди: разделитель, который
|
// Поля переменной длины идут с длиной впереди: разделитель, который
|
||||||
// может встретиться ВНУТРИ поля, даёт одну строку для разных состояний,
|
// может встретиться ВНУТРИ поля, даёт одну строку для разных состояний,
|
||||||
@@ -734,13 +839,57 @@ func (s *Store) Fingerprint(ctx context.Context) (string, error) {
|
|||||||
// здесь значит получить «состояние совпало» при разошедшемся состоянии —
|
// здесь значит получить «состояние совпало» при разошедшемся состоянии —
|
||||||
// то есть сломать молча ровно тот оракул, ради которого отпечаток и
|
// то есть сломать молча ровно тот оракул, ради которого отпечаток и
|
||||||
// заведён. Тот же приём в canon.HashAll и по той же причине.
|
// заведён. Тот же приём в canon.HashAll и по той же причине.
|
||||||
fmt.Fprintf(h, "%d:%s|%d:%s|%s|%s|%d|%d:%s|%t\n",
|
fmt.Fprintf(h, "%s|%d:%s|%d:%s|%s|%s|%d|%d:%s|%t\n",
|
||||||
len(metric), metric, len(layer), layer, hour, hash, points, len(units), units, sealed)
|
fpBucket, len(metric), metric, len(layer), layer, hour, hash, points,
|
||||||
|
len(units), units, sealed)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return "", fmt.Errorf("select buckets: %w", err)
|
return fmt.Errorf("select buckets: %w", err)
|
||||||
}
|
}
|
||||||
return hex.EncodeToString(h.Sum(nil)), nil
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fingerprintEntities(ctx context.Context, tx *sql.Tx, h io.Writer) error {
|
||||||
|
queries := []struct {
|
||||||
|
tag string
|
||||||
|
sql string
|
||||||
|
}{
|
||||||
|
// Род и идентификатор идут ОТДЕЛЬНЫМИ полями, каждое со своей длиной, а
|
||||||
|
// не склейкой `kind || '/' || id`: склейка выполняется до взятия длины,
|
||||||
|
// и пара (`a`, `b/c`) даёт ту же строку, что (`a/b`, `c`). Отпечаток —
|
||||||
|
// единственный оракул сходимости, по нему принимается необратимое
|
||||||
|
// решение о подмене базы; два разных состояния витрины не имеют права
|
||||||
|
// дать один отпечаток. У тренировки род один и в строку не идёт.
|
||||||
|
{fpWorkout, `SELECT '', id, start_utc, content_hash FROM workout ORDER BY id`},
|
||||||
|
{fpRecord, `SELECT kind, id, ts_utc, content_hash FROM record ORDER BY kind, id`},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, q := range queries {
|
||||||
|
if err := fingerprintRows(ctx, tx, h, q.tag, q.sql); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fingerprintRows(ctx context.Context, tx *sql.Tx, h io.Writer, tag, query string) error {
|
||||||
|
rows, err := tx.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("select entities: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var kind, id, ts, hash string
|
||||||
|
if err := rows.Scan(&kind, &id, &ts, &hash); err != nil {
|
||||||
|
return fmt.Errorf("scan entity: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(h, "%s|%d:%s|%d:%s|%s|%s\n", tag, len(kind), kind, len(id), id, ts, hash)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return fmt.Errorf("select entities: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bucket читает объект по координатам. Нужен тестам и будущему Read API.
|
// Bucket читает объект по координатам. Нужен тестам и будущему Read API.
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func point(t *testing.T, metric, layer, start, end, raw string) store.IncomingPo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMergePointsКладётЧасОднимОбъектом(t *testing.T) {
|
func TestMergeКладётЧасОднимОбъектом(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
st := open(t)
|
st := open(t)
|
||||||
@@ -63,7 +63,7 @@ func TestMergePointsКладётЧасОднимОбъектом(t *testing.T) {
|
|||||||
point(t, "step_count", "minute", "2025-06-05T11:00:00Z", "2025-06-05T11:00:00Z", `{"qty":3}`),
|
point(t, "step_count", "minute", "2025-06-05T11:00:00Z", "2025-06-05T11:00:00Z", `{"qty":3}`),
|
||||||
}
|
}
|
||||||
|
|
||||||
stats, err := st.MergePoints(ctx, in, "delivery-1")
|
stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "delivery-1"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("слияние: %v", err)
|
t.Fatalf("слияние: %v", err)
|
||||||
}
|
}
|
||||||
@@ -91,7 +91,7 @@ func TestMergePointsКладётЧасОднимОбъектом(t *testing.T) {
|
|||||||
|
|
||||||
// Дозапись в существующий час: объект перечитывается, точки сливаются, ранее
|
// Дозапись в существующий час: объект перечитывается, точки сливаются, ранее
|
||||||
// сохранённые остаются. Точки из объекта не удаляются никогда.
|
// сохранённые остаются. Точки из объекта не удаляются никогда.
|
||||||
func TestMergePointsДозаписьНеТеряетСохранённое(t *testing.T) {
|
func TestMergeДозаписьНеТеряетСохранённое(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
st := open(t)
|
st := open(t)
|
||||||
@@ -104,10 +104,10 @@ func TestMergePointsДозаписьНеТеряетСохранённое(t *te
|
|||||||
point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`),
|
point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`),
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := st.MergePoints(ctx, first, "d1"); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: first}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||||
t.Fatalf("первое слияние: %v", err)
|
t.Fatalf("первое слияние: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := st.MergePoints(ctx, second, "d2"); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: second}, store.DeliveryRef{ID: "d2"}); err != nil {
|
||||||
t.Fatalf("второе слияние: %v", err)
|
t.Fatalf("второе слияние: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ func TestMergePointsДозаписьНеТеряетСохранённое(t *te
|
|||||||
|
|
||||||
// Хеш — детектор изменений: повтор того же часа не пишет в базу. Именно это
|
// Хеш — детектор изменений: повтор того же часа не пишет в базу. Именно это
|
||||||
// делает широкие проходы синхронизации дешёвыми.
|
// делает широкие проходы синхронизации дешёвыми.
|
||||||
func TestMergePointsПовторНеПишет(t *testing.T) {
|
func TestMergeПовторНеПишет(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
st := open(t)
|
st := open(t)
|
||||||
@@ -136,7 +136,7 @@ func TestMergePointsПовторНеПишет(t *testing.T) {
|
|||||||
point(t, "step_count", "minute", "2025-06-05T10:01:00Z", "2025-06-05T10:01:00Z", `{"qty":2,"source":"Device A"}`),
|
point(t, "step_count", "minute", "2025-06-05T10:01:00Z", "2025-06-05T10:01:00Z", `{"qty":2,"source":"Device A"}`),
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := st.MergePoints(ctx, in, "d1"); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||||
t.Fatalf("первое слияние: %v", err)
|
t.Fatalf("первое слияние: %v", err)
|
||||||
}
|
}
|
||||||
before, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
before, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
||||||
@@ -144,7 +144,7 @@ func TestMergePointsПовторНеПишет(t *testing.T) {
|
|||||||
t.Fatalf("чтение: %v", err)
|
t.Fatalf("чтение: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stats, err := st.MergePoints(ctx, in, "d2")
|
stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d2"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("повторное слияние: %v", err)
|
t.Fatalf("повторное слияние: %v", err)
|
||||||
}
|
}
|
||||||
@@ -172,7 +172,7 @@ func TestMergePointsПовторНеПишет(t *testing.T) {
|
|||||||
|
|
||||||
// Порядок точек внутри доставки нестабилен (находка 2), и идентичность не
|
// Порядок точек внутри доставки нестабилен (находка 2), и идентичность не
|
||||||
// имеет права от него зависеть.
|
// имеет права от него зависеть.
|
||||||
func TestMergePointsИдемпотентенКПорядкуТочек(t *testing.T) {
|
func TestMergeИдемпотентенКПорядкуТочек(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
st := open(t)
|
st := open(t)
|
||||||
@@ -185,7 +185,7 @@ func TestMergePointsИдемпотентенКПорядкуТочек(t *testin
|
|||||||
}
|
}
|
||||||
reversed := []store.IncomingPoint{in[2], in[1], in[0]}
|
reversed := []store.IncomingPoint{in[2], in[1], in[0]}
|
||||||
|
|
||||||
if _, err := st.MergePoints(ctx, in, "d1"); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||||
t.Fatalf("первое слияние: %v", err)
|
t.Fatalf("первое слияние: %v", err)
|
||||||
}
|
}
|
||||||
first, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
first, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
||||||
@@ -193,7 +193,7 @@ func TestMergePointsИдемпотентенКПорядкуТочек(t *testin
|
|||||||
t.Fatalf("чтение: %v", err)
|
t.Fatalf("чтение: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stats, err := st.MergePoints(ctx, reversed, "d2")
|
stats, err := st.Merge(ctx, store.Incoming{Points: reversed}, store.DeliveryRef{ID: "d2"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("слияние в обратном порядке: %v", err)
|
t.Fatalf("слияние в обратном порядке: %v", err)
|
||||||
}
|
}
|
||||||
@@ -213,7 +213,7 @@ func TestMergePointsИдемпотентенКПорядкуТочек(t *testin
|
|||||||
// Ключ по метке схлопнул бы эти три записи в одну. Живьём такое встречается в
|
// Ключ по метке схлопнул бы эти три записи в одну. Живьём такое встречается в
|
||||||
// 22 доставках из 94 (находка 47), причём внутри одной доставки — там тай-брейк
|
// 22 доставках из 94 (находка 47), причём внутри одной доставки — там тай-брейк
|
||||||
// по времени приёма неприменим в принципе.
|
// по времени приёма неприменим в принципе.
|
||||||
func TestMergePointsЗаписиСОднойМеткойНеСхлопываются(t *testing.T) {
|
func TestMergeЗаписиСОднойМеткойНеСхлопываются(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
st := open(t)
|
st := open(t)
|
||||||
@@ -225,7 +225,7 @@ func TestMergePointsЗаписиСОднойМеткойНеСхлопывают
|
|||||||
point(t, "sleep_analysis", "minute", "2025-06-05T19:04:00Z", "2025-06-06T04:51:00Z", `{"qty":9.78,"value":"В кровати"}`),
|
point(t, "sleep_analysis", "minute", "2025-06-05T19:04:00Z", "2025-06-06T04:51:00Z", `{"qty":9.78,"value":"В кровати"}`),
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := st.MergePoints(ctx, in, "d1"); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||||
t.Fatalf("слияние: %v", err)
|
t.Fatalf("слияние: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +239,7 @@ func TestMergePointsЗаписиСОднойМеткойНеСхлопывают
|
|||||||
|
|
||||||
// Повтор той же тройки не задваивает: координата включает интервал, и он
|
// Повтор той же тройки не задваивает: координата включает интервал, и он
|
||||||
// совпадает.
|
// совпадает.
|
||||||
stats, err := st.MergePoints(ctx, in, "d2")
|
stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d2"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("повтор: %v", err)
|
t.Fatalf("повтор: %v", err)
|
||||||
}
|
}
|
||||||
@@ -250,7 +250,7 @@ func TestMergePointsЗаписиСОднойМеткойНеСхлопывают
|
|||||||
|
|
||||||
// Эпизод, пересекающий границу часа, ложится в объект по НАЧАЛУ: любой другой
|
// Эпизод, пересекающий границу часа, ложится в объект по НАЧАЛУ: любой другой
|
||||||
// выбор сделал бы принадлежность объекту зависящей от длительности.
|
// выбор сделал бы принадлежность объекту зависящей от длительности.
|
||||||
func TestMergePointsЧасПоНачалуИнтервала(t *testing.T) {
|
func TestMergeЧасПоНачалуИнтервала(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
st := open(t)
|
st := open(t)
|
||||||
@@ -259,7 +259,7 @@ func TestMergePointsЧасПоНачалуИнтервала(t *testing.T) {
|
|||||||
in := []store.IncomingPoint{
|
in := []store.IncomingPoint{
|
||||||
point(t, "sleep_analysis", "minute", "2025-06-05T19:04:00Z", "2025-06-06T04:51:00Z", `{"qty":9.78}`),
|
point(t, "sleep_analysis", "minute", "2025-06-05T19:04:00Z", "2025-06-06T04:51:00Z", `{"qty":9.78}`),
|
||||||
}
|
}
|
||||||
if _, err := st.MergePoints(ctx, in, "d1"); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||||
t.Fatalf("слияние: %v", err)
|
t.Fatalf("слияние: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +274,7 @@ func TestMergePointsЧасПоНачалуИнтервала(t *testing.T) {
|
|||||||
// Правило разрешения столкновений: выигрывает более полная точка, а не
|
// Правило разрешения столкновений: выигрывает более полная точка, а не
|
||||||
// последняя пришедшая. Иначе бедная доставка стирает у богатой поля, которых
|
// последняя пришедшая. Иначе бедная доставка стирает у богатой поля, которых
|
||||||
// сама не несёт.
|
// сама не несёт.
|
||||||
func TestMergePointsБеднаяТочкаНеСтираетБогатую(t *testing.T) {
|
func TestMergeБеднаяТочкаНеСтираетБогатую(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
st := open(t)
|
st := open(t)
|
||||||
@@ -285,10 +285,10 @@ func TestMergePointsБеднаяТочкаНеСтираетБогатую(t *te
|
|||||||
poor := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
poor := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||||
`{"Avg":60,"Min":55,"Max":70}`)
|
`{"Avg":60,"Min":55,"Max":70}`)
|
||||||
|
|
||||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{rich}, "d1"); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{rich}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||||
t.Fatalf("первое слияние: %v", err)
|
t.Fatalf("первое слияние: %v", err)
|
||||||
}
|
}
|
||||||
stats, err := st.MergePoints(ctx, []store.IncomingPoint{poor}, "d2")
|
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{poor}}, store.DeliveryRef{ID: "d2"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("второе слияние: %v", err)
|
t.Fatalf("второе слияние: %v", err)
|
||||||
}
|
}
|
||||||
@@ -311,7 +311,7 @@ func TestMergePointsБеднаяТочкаНеСтираетБогатую(t *te
|
|||||||
// Полнота — множество ключей, а не их число. Счётчик значащих полей давал
|
// Полнота — множество ключей, а не их число. Счётчик значащих полей давал
|
||||||
// сохранённой точке 5 против 2 и стирал настоящее измерение безвозвратно:
|
// сохранённой точке 5 против 2 и стирал настоящее измерение безвозвратно:
|
||||||
// восстановить его можно было бы только из сырого архива, пока он жив.
|
// восстановить его можно было бы только из сырого архива, пока он жив.
|
||||||
func TestMergePointsПоляБезСодержанияНеСтираютИзмерение(t *testing.T) {
|
func TestMergeПоляБезСодержанияНеСтираютИзмерение(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
st := open(t)
|
st := open(t)
|
||||||
@@ -322,10 +322,10 @@ func TestMergePointsПоляБезСодержанияНеСтираютИзме
|
|||||||
measured := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
measured := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||||
`{"date":"2026-07-31 12:00:00 +0300","qty":123.4}`)
|
`{"date":"2026-07-31 12:00:00 +0300","qty":123.4}`)
|
||||||
|
|
||||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{hollow}, "d1"); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{hollow}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||||
t.Fatalf("первое слияние: %v", err)
|
t.Fatalf("первое слияние: %v", err)
|
||||||
}
|
}
|
||||||
stats, err := st.MergePoints(ctx, []store.IncomingPoint{measured}, "d2")
|
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{measured}}, store.DeliveryRef{ID: "d2"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("второе слияние: %v", err)
|
t.Fatalf("второе слияние: %v", err)
|
||||||
}
|
}
|
||||||
@@ -345,7 +345,7 @@ func TestMergePointsПоляБезСодержанияНеСтираютИзме
|
|||||||
|
|
||||||
// Поле с нулевым значением содержания не несёт, но и теряться не должно: при
|
// Поле с нулевым значением содержания не несёт, но и теряться не должно: при
|
||||||
// равном множестве содержательных ключей выигрывает точка со всеми ключами.
|
// равном множестве содержательных ключей выигрывает точка со всеми ключами.
|
||||||
func TestMergePointsРавноеСодержаниеНеТеряетПоля(t *testing.T) {
|
func TestMergeРавноеСодержаниеНеТеряетПоля(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
st := open(t)
|
st := open(t)
|
||||||
@@ -356,10 +356,10 @@ func TestMergePointsРавноеСодержаниеНеТеряетПоля(t *
|
|||||||
narrow := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
narrow := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||||
`{"date":"d","qty":12}`)
|
`{"date":"d","qty":12}`)
|
||||||
|
|
||||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{wide}, "d1"); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{wide}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||||
t.Fatalf("первое слияние: %v", err)
|
t.Fatalf("первое слияние: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{narrow}, "d2"); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{narrow}}, store.DeliveryRef{ID: "d2"}); err != nil {
|
||||||
t.Fatalf("второе слияние: %v", err)
|
t.Fatalf("второе слияние: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,7 +375,7 @@ func TestMergePointsРавноеСодержаниеНеТеряетПоля(t *
|
|||||||
// Несравнимые наборы полей на живом потоке не встретились ни разу (0 из 2 897
|
// Несравнимые наборы полей на живом потоке не встретились ни разу (0 из 2 897
|
||||||
// столкновений), поэтому объединение полей не реализовано. Взамен — наблюдение:
|
// столкновений), поэтому объединение полей не реализовано. Взамен — наблюдение:
|
||||||
// счётчик и координаты объекта, по которым событие можно будет разобрать.
|
// счётчик и координаты объекта, по которым событие можно будет разобрать.
|
||||||
func TestMergePointsНесравнимыеНаборыСчитаются(t *testing.T) {
|
func TestMergeНесравнимыеНаборыСчитаются(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
st := open(t)
|
st := open(t)
|
||||||
@@ -386,10 +386,10 @@ func TestMergePointsНесравнимыеНаборыСчитаются(t *test
|
|||||||
b := point(t, "blood_glucose", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
b := point(t, "blood_glucose", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||||
`{"mealTime":"До еды"}`)
|
`{"mealTime":"До еды"}`)
|
||||||
|
|
||||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{a}, "d1"); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{a}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||||
t.Fatalf("первое слияние: %v", err)
|
t.Fatalf("первое слияние: %v", err)
|
||||||
}
|
}
|
||||||
stats, err := st.MergePoints(ctx, []store.IncomingPoint{b}, "d2")
|
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{b}}, store.DeliveryRef{ID: "d2"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("второе слияние: %v", err)
|
t.Fatalf("второе слияние: %v", err)
|
||||||
}
|
}
|
||||||
@@ -420,7 +420,7 @@ func TestMergePointsНесравнимыеНаборыСчитаются(t *test
|
|||||||
|
|
||||||
// Список координат упирается в потолок, счётчик — нет: обрезанный список
|
// Список координат упирается в потолок, счётчик — нет: обрезанный список
|
||||||
// остаётся зацепкой для разбора, а масштаб события считает счётчик.
|
// остаётся зацепкой для разбора, а масштаб события считает счётчик.
|
||||||
func TestMergePointsСчётчикРастётПослеПотолкаКоординат(t *testing.T) {
|
func TestMergeСчётчикРастётПослеПотолкаКоординат(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
st := open(t)
|
st := open(t)
|
||||||
@@ -434,10 +434,10 @@ func TestMergePointsСчётчикРастётПослеПотолкаКоорд
|
|||||||
second = append(second, point(t, "blood_glucose", "minute", at, at, `{"mealTime":"До еды"}`))
|
second = append(second, point(t, "blood_glucose", "minute", at, at, `{"mealTime":"До еды"}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := st.MergePoints(ctx, first, "d1"); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: first}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||||
t.Fatalf("первое слияние: %v", err)
|
t.Fatalf("первое слияние: %v", err)
|
||||||
}
|
}
|
||||||
stats, err := st.MergePoints(ctx, second, "d2")
|
stats, err := st.Merge(ctx, store.Incoming{Points: second}, store.DeliveryRef{ID: "d2"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("второе слияние: %v", err)
|
t.Fatalf("второе слияние: %v", err)
|
||||||
}
|
}
|
||||||
@@ -453,7 +453,7 @@ func TestMergePointsСчётчикРастётПослеПотолкаКоорд
|
|||||||
|
|
||||||
// source нестабилен: то же измерение приезжает то с одним именем устройства,
|
// source нестабилен: то же измерение приезжает то с одним именем устройства,
|
||||||
// то с другим. Он не входит в ключ и не считается полнотой.
|
// то с другим. Он не входит в ключ и не считается полнотой.
|
||||||
func TestMergePointsСменаИсточникаНеСоздаётВторуюТочку(t *testing.T) {
|
func TestMergeСменаИсточникаНеСоздаётВторуюТочку(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
st := open(t)
|
st := open(t)
|
||||||
@@ -464,10 +464,10 @@ func TestMergePointsСменаИсточникаНеСоздаётВторуюТ
|
|||||||
b := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
b := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||||
`{"qty":1,"source":"Apple Watch Ultra 3"}`)
|
`{"qty":1,"source":"Apple Watch Ultra 3"}`)
|
||||||
|
|
||||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{a}, "d1"); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{a}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||||
t.Fatalf("первое слияние: %v", err)
|
t.Fatalf("первое слияние: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{b}, "d2"); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{b}}, store.DeliveryRef{ID: "d2"}); err != nil {
|
||||||
t.Fatalf("второе слияние: %v", err)
|
t.Fatalf("второе слияние: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,7 +483,7 @@ func TestMergePointsСменаИсточникаНеСоздаётВторуюТ
|
|||||||
// Исход столкновения точек равной полноты обязан зависеть только от значений:
|
// Исход столкновения точек равной полноты обязан зависеть только от значений:
|
||||||
// свёртка по журналу должна давать то же состояние, что приём в реальном
|
// свёртка по журналу должна давать то же состояние, что приём в реальном
|
||||||
// времени, а внутри одной доставки время приёма общее.
|
// времени, а внутри одной доставки время приёма общее.
|
||||||
func TestMergePointsРавнаяПолнотаРазрешаетсяДетерминированно(t *testing.T) {
|
func TestMergeРавнаяПолнотаРазрешаетсяДетерминированно(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -494,7 +494,7 @@ func TestMergePointsРавнаяПолнотаРазрешаетсяДетерм
|
|||||||
winner := func(order []store.IncomingPoint) string {
|
winner := func(order []store.IncomingPoint) string {
|
||||||
st := open(t)
|
st := open(t)
|
||||||
for _, p := range order {
|
for _, p := range order {
|
||||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{p}, "d"); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p}}, store.DeliveryRef{ID: "d"}); err != nil {
|
||||||
t.Fatalf("слияние: %v", err)
|
t.Fatalf("слияние: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -515,7 +515,7 @@ func TestMergePointsРавнаяПолнотаРазрешаетсяДетерм
|
|||||||
// Содержимое точки хранится исходными байтами: пересборка повторной
|
// Содержимое точки хранится исходными байтами: пересборка повторной
|
||||||
// сериализацией теряет литерал, и потеря не видна тестам, сравнивающим
|
// сериализацией теряет литерал, и потеря не видна тестам, сравнивающим
|
||||||
// разобранное с разобранным.
|
// разобранное с разобранным.
|
||||||
func TestMergePointsХранитТочкуДословно(t *testing.T) {
|
func TestMergeХранитТочкуДословно(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
st := open(t)
|
st := open(t)
|
||||||
@@ -525,7 +525,7 @@ func TestMergePointsХранитТочкуДословно(t *testing.T) {
|
|||||||
in := []store.IncomingPoint{
|
in := []store.IncomingPoint{
|
||||||
point(t, "unknown", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", raw),
|
point(t, "unknown", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", raw),
|
||||||
}
|
}
|
||||||
if _, err := st.MergePoints(ctx, in, "d1"); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||||
t.Fatalf("слияние: %v", err)
|
t.Fatalf("слияние: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,7 +541,7 @@ func TestMergePointsХранитТочкуДословно(t *testing.T) {
|
|||||||
// Тело доставки доходит до 42 МиБ, приходят они непрерывно и внахлёст.
|
// Тело доставки доходит до 42 МиБ, приходят они непрерывно и внахлёст.
|
||||||
// Конкурентное слияние того же часа не имеет права терять точки: между
|
// Конкурентное слияние того же часа не имеет права терять точки: между
|
||||||
// чтением и записью может вклиниться другая доставка.
|
// чтением и записью может вклиниться другая доставка.
|
||||||
func TestMergePointsКонкурентноеСлияниеНеТеряетТочки(t *testing.T) {
|
func TestMergeКонкурентноеСлияниеНеТеряетТочки(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
st := open(t)
|
st := open(t)
|
||||||
@@ -568,7 +568,7 @@ func TestMergePointsКонкурентноеСлияниеНеТеряетТоч
|
|||||||
Raw: json.RawMessage(`{"qty":` + itoa(minute) + `}`),
|
Raw: json.RawMessage(`{"qty":` + itoa(minute) + `}`),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{p}, "d"); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p}}, store.DeliveryRef{ID: "d"}); err != nil {
|
||||||
errs <- err
|
errs <- err
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -607,7 +607,7 @@ func itoa(n int) string {
|
|||||||
// Изменение запечатанного часа — сигнал, а не отказ: данные пишутся всё равно,
|
// Изменение запечатанного часа — сигнал, а не отказ: данные пишутся всё равно,
|
||||||
// но факт обязан дойти до владельца сервиса. Без счётчика допущение «глубже
|
// но факт обязан дойти до владельца сервиса. Без счётчика допущение «глубже
|
||||||
// такого-то порога досчёта не бывает» не получило бы ни одного наблюдения.
|
// такого-то порога досчёта не бывает» не получило бы ни одного наблюдения.
|
||||||
func TestMergePointsИзменениеЗапечатанногоЧаса(t *testing.T) {
|
func TestMergeИзменениеЗапечатанногоЧаса(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
st := open(t)
|
st := open(t)
|
||||||
@@ -617,7 +617,7 @@ func TestMergePointsИзменениеЗапечатанногоЧаса(t *test
|
|||||||
first := []store.IncomingPoint{
|
first := []store.IncomingPoint{
|
||||||
point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
|
point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
|
||||||
}
|
}
|
||||||
if _, err := st.MergePoints(ctx, first, "d1"); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: first}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||||
t.Fatalf("первое слияние: %v", err)
|
t.Fatalf("первое слияние: %v", err)
|
||||||
}
|
}
|
||||||
if err := st.MarkSealed(ctx, "step_count", "minute", hour, true); err != nil {
|
if err := st.MarkSealed(ctx, "step_count", "minute", hour, true); err != nil {
|
||||||
@@ -627,7 +627,7 @@ func TestMergePointsИзменениеЗапечатанногоЧаса(t *test
|
|||||||
late := []store.IncomingPoint{
|
late := []store.IncomingPoint{
|
||||||
point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`),
|
point(t, "step_count", "minute", "2025-06-05T10:30:00Z", "2025-06-05T10:30:00Z", `{"qty":2}`),
|
||||||
}
|
}
|
||||||
stats, err := st.MergePoints(ctx, late, "d2")
|
stats, err := st.Merge(ctx, store.Incoming{Points: late}, store.DeliveryRef{ID: "d2"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("досчёт запечатанного часа: %v", err)
|
t.Fatalf("досчёт запечатанного часа: %v", err)
|
||||||
}
|
}
|
||||||
@@ -649,7 +649,7 @@ func TestMergePointsИзменениеЗапечатанногоЧаса(t *test
|
|||||||
|
|
||||||
// Отмена посреди слияния не имеет права оставить половину: объект либо
|
// Отмена посреди слияния не имеет права оставить половину: объект либо
|
||||||
// прежний, либо полный.
|
// прежний, либо полный.
|
||||||
func TestMergePointsОтменаНеОставляетПоловины(t *testing.T) {
|
func TestMergeОтменаНеОставляетПоловины(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
st := open(t)
|
st := open(t)
|
||||||
@@ -657,19 +657,30 @@ func TestMergePointsОтменаНеОставляетПоловины(t *testin
|
|||||||
base := []store.IncomingPoint{
|
base := []store.IncomingPoint{
|
||||||
point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
|
point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
|
||||||
}
|
}
|
||||||
if _, err := st.MergePoints(context.Background(), base, "d1"); err != nil {
|
if _, err := st.Merge(context.Background(), store.Incoming{Points: base}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||||
t.Fatalf("первое слияние: %v", err)
|
t.Fatalf("первое слияние: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
more := []store.IncomingPoint{
|
// Сущности идут в ТОЙ ЖЕ транзакции и пишутся ПОСЛЕ объектов, то есть на
|
||||||
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("слияние на отменённом контексте прошло успешно")
|
t.Fatal("слияние на отменённом контексте прошло успешно")
|
||||||
}
|
}
|
||||||
|
if _, err := st.Workout(context.Background(), "w-отменённая"); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Errorf("тренировка отменённой доставки осталась в витрине: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
b, err := st.Bucket(context.Background(), "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
b, err := st.Bucket(context.Background(), "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -704,7 +715,7 @@ func cycleTriple(t *testing.T) []store.IncomingPoint {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMergePointsПовторнаяСвёрткаНеМеняетСостояние(t *testing.T) {
|
func TestMergeПовторнаяСвёрткаНеМеняетСостояние(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
st := open(t)
|
st := open(t)
|
||||||
@@ -714,7 +725,7 @@ func TestMergePointsПовторнаяСвёрткаНеМеняетСостоя
|
|||||||
state := func() (string, string) {
|
state := func() (string, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
if _, err := st.MergePoints(ctx, in, "d1"); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||||
t.Fatalf("слияние: %v", err)
|
t.Fatalf("слияние: %v", err)
|
||||||
}
|
}
|
||||||
b, err := st.Bucket(ctx, "heart_rate", "raw", ts(t, "2025-06-05T10:00:00Z"))
|
b, err := st.Bucket(ctx, "heart_rate", "raw", ts(t, "2025-06-05T10:00:00Z"))
|
||||||
@@ -738,7 +749,7 @@ func TestMergePointsПовторнаяСвёрткаНеМеняетСостоя
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMergePointsИсходНеЗависитОтПерестановки(t *testing.T) {
|
func TestMergeИсходНеЗависитОтПерестановки(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -752,7 +763,7 @@ func TestMergePointsИсходНеЗависитОтПерестановки(t *
|
|||||||
st := open(t)
|
st := open(t)
|
||||||
if split {
|
if split {
|
||||||
for _, i := range order {
|
for _, i := range order {
|
||||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{in[i]}, "d"); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{in[i]}}, store.DeliveryRef{ID: "d"}); err != nil {
|
||||||
t.Fatalf("слияние: %v", err)
|
t.Fatalf("слияние: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -761,7 +772,7 @@ func TestMergePointsИсходНеЗависитОтПерестановки(t *
|
|||||||
for _, i := range order {
|
for _, i := range order {
|
||||||
batch = append(batch, in[i])
|
batch = append(batch, in[i])
|
||||||
}
|
}
|
||||||
if _, err := st.MergePoints(ctx, batch, "d"); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: batch}, store.DeliveryRef{ID: "d"}); err != nil {
|
||||||
t.Fatalf("слияние: %v", err)
|
t.Fatalf("слияние: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -785,7 +796,7 @@ func TestMergePointsИсходНеЗависитОтПерестановки(t *
|
|||||||
// Точка, ни одно значение которой не несёт измерения, не должна вытеснять
|
// Точка, ни одно значение которой не несёт измерения, не должна вытеснять
|
||||||
// настоящее измерение. Раньше вытесняла: множества содержательных ключей
|
// настоящее измерение. Раньше вытесняла: множества содержательных ключей
|
||||||
// равны, и решал второй разряд — по ключам, а не по содержанию.
|
// равны, и решал второй разряд — по ключам, а не по содержанию.
|
||||||
func TestMergePointsПадингНеВытесняетИзмерение(t *testing.T) {
|
func TestMergeПадингНеВытесняетИзмерение(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -808,7 +819,7 @@ func TestMergePointsПадингНеВытесняетИзмерение(t *test
|
|||||||
point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", real),
|
point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", real),
|
||||||
point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", c.junk),
|
point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", c.junk),
|
||||||
}
|
}
|
||||||
stats, err := st.MergePoints(ctx, in, "d1")
|
stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("слияние: %v", err)
|
t.Fatalf("слияние: %v", err)
|
||||||
}
|
}
|
||||||
@@ -824,7 +835,7 @@ func TestMergePointsПадингНеВытесняетИзмерение(t *test
|
|||||||
// Имя метрики приходит из тела доставки дословно и ничем не ограничено.
|
// Имя метрики приходит из тела доставки дословно и ничем не ограничено.
|
||||||
// Без обрезки одна доставка порождает WARN-строку в десятки мегабайт и
|
// Без обрезки одна доставка порождает WARN-строку в десятки мегабайт и
|
||||||
// вытесняет из ротации логов всю недавнюю историю.
|
// вытесняет из ротации логов всю недавнюю историю.
|
||||||
func TestMergePointsИмяМетрикиВКоординатеОбрезано(t *testing.T) {
|
func TestMergeИмяМетрикиВКоординатеОбрезано(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
st := open(t)
|
st := open(t)
|
||||||
@@ -835,7 +846,7 @@ func TestMergePointsИмяМетрикиВКоординатеОбрезано(t
|
|||||||
point(t, huge, "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
|
point(t, huge, "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
|
||||||
point(t, huge, "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":2}`),
|
point(t, huge, "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":2}`),
|
||||||
}
|
}
|
||||||
stats, err := st.MergePoints(ctx, in, "d1")
|
stats, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("слияние: %v", err)
|
t.Fatalf("слияние: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// workout собирает тренировку с заданным содержимым. Заголовок в этих тестах
|
||||||
|
// вторичен: правило замены смотрит на содержание, а не на колонки.
|
||||||
|
func workout(t *testing.T, id, raw string) store.IncomingEntity {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return store.IncomingEntity{
|
||||||
|
ID: id,
|
||||||
|
Kind: "workouts",
|
||||||
|
Name: "На улице Ходьба",
|
||||||
|
Start: ts(t, "2025-06-05T07:00:00Z"),
|
||||||
|
End: ts(t, "2025-06-05T07:10:00Z"),
|
||||||
|
OffsetSeconds: 3 * 3600,
|
||||||
|
Raw: json.RawMessage(raw),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func from(t *testing.T, id, receivedAt string) store.DeliveryRef {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return store.DeliveryRef{ID: id, ReceivedAt: ts(t, receivedAt)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeWorkouts(t *testing.T, st *store.Store, d store.DeliveryRef, ws ...store.IncomingEntity) store.MergeStats {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
stats, err := st.Merge(context.Background(), store.Incoming{Workouts: ws}, d)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("слияние сущностей: %v", err)
|
||||||
|
}
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Содержимое подобрано так, чтобы отличаться от прежнего ЗНАЧЕНИЯМИ общих
|
||||||
|
// полей: именно так тренировка и меняется между версиями (досчёт энергии),
|
||||||
|
// и именно на этом ломается правило полноты, написанное для точек.
|
||||||
|
woWithRoute = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1},{"lat":2},{"lat":3}],
|
||||||
|
"activeEnergy":[{"qty":10}],"totalEnergy":{"qty":20,"units":"kJ"}}`
|
||||||
|
woNoRouteNewValues = `{"id":"w1","name":"На улице Ходьба",
|
||||||
|
"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"}}`
|
||||||
|
woShortRoute = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1}],
|
||||||
|
"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"}}`
|
||||||
|
woRicher = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1},{"lat":2},{"lat":3}],
|
||||||
|
"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"},"stepCount":{"qty":900}}`
|
||||||
|
woSameShapeNewValues = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1},{"lat":2},{"lat":3}],
|
||||||
|
"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"}}`
|
||||||
|
woIncomparable = `{"id":"w1","name":"На улице Ходьба",
|
||||||
|
"activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"},"flightsClimbed":{"qty":3}}`
|
||||||
|
)
|
||||||
|
|
||||||
|
func storedRaw(t *testing.T, st *store.Store, id string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
w, err := st.Workout(context.Background(), id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("чтение тренировки: %v", err)
|
||||||
|
}
|
||||||
|
return string(w.Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Единственная причина повторной присылки тренировки — доезжающий маршрут.
|
||||||
|
func TestMergeДоехавшийМаршрутЗамещаетТренировку(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
st := open(t)
|
||||||
|
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woNoRouteNewValues))
|
||||||
|
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woWithRoute))
|
||||||
|
|
||||||
|
if stats.WorkoutsWritten != 1 {
|
||||||
|
t.Errorf("записано %d, ожидалась 1: версия с маршрутом полнее", stats.WorkoutsWritten)
|
||||||
|
}
|
||||||
|
if stats.EntitiesHeld != 0 {
|
||||||
|
t.Errorf("удержано %d, ожидалось 0", stats.EntitiesHeld)
|
||||||
|
}
|
||||||
|
if got := storedRaw(t, st, "w1"); got != woWithRoute {
|
||||||
|
t.Error("в витрине не версия с маршрутом")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тренировка досчитывается задним числом ровно так же, как минутное ведро:
|
||||||
|
// набор полей тот же, значения новые. Тай-брейк по канонической форме
|
||||||
|
// заморозил бы её на произвольной версии навсегда.
|
||||||
|
func TestMergeДосчётПриТомЖеНабореПолейПобеждает(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
st := open(t)
|
||||||
|
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||||
|
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woSameShapeNewValues))
|
||||||
|
|
||||||
|
if stats.WorkoutsWritten != 1 {
|
||||||
|
t.Errorf("записано %d, ожидалась 1", stats.WorkoutsWritten)
|
||||||
|
}
|
||||||
|
if got := storedRaw(t, st, "w1"); got != woSameShapeNewValues {
|
||||||
|
t.Error("досчитанная версия не легла в витрину")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Исход обязан быть функцией ЖУРНАЛА, а не порядка свёртки: воркер сворачивает
|
||||||
|
// в порядке журнала только среди видимых ему доставок, и доставка с более
|
||||||
|
// ранней меткой может свернуться позже.
|
||||||
|
func TestMergeВерсияИзБолееРаннейДоставкиНеОткатываетВитрину(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
early := from(t, "d1", "2025-06-05T08:00:00Z")
|
||||||
|
late := from(t, "d2", "2025-06-05T08:05:00Z")
|
||||||
|
|
||||||
|
прямой := open(t)
|
||||||
|
mergeWorkouts(t, прямой, early, workout(t, "w1", woWithRoute))
|
||||||
|
mergeWorkouts(t, прямой, late, workout(t, "w1", woSameShapeNewValues))
|
||||||
|
|
||||||
|
обратный := open(t)
|
||||||
|
mergeWorkouts(t, обратный, late, workout(t, "w1", woSameShapeNewValues))
|
||||||
|
mergeWorkouts(t, обратный, early, workout(t, "w1", woWithRoute))
|
||||||
|
|
||||||
|
a, b := storedRaw(t, прямой, "w1"), storedRaw(t, обратный, "w1")
|
||||||
|
if a != b {
|
||||||
|
t.Error("исход зависит от порядка свёртки — живая витрина разойдётся с пересборкой")
|
||||||
|
}
|
||||||
|
if a != woSameShapeNewValues {
|
||||||
|
t.Error("победила версия не из более поздней доставки журнала")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Маршрут — 95% содержимого тренировки, а восстановление требует пересборки
|
||||||
|
// всего журнала. Событие делается наблюдаемым, а не необратимым.
|
||||||
|
func TestMergeОбеднённаяВерсияНеЗатираетСохранённую(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
st := open(t)
|
||||||
|
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||||
|
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woNoRouteNewValues))
|
||||||
|
|
||||||
|
if stats.WorkoutsWritten != 0 {
|
||||||
|
t.Errorf("записано %d, ожидалось 0: приехавшая теряет маршрут", stats.WorkoutsWritten)
|
||||||
|
}
|
||||||
|
if stats.EntitiesHeld != 1 {
|
||||||
|
t.Errorf("удержано %d, ожидалась 1 — событие обязано быть видно", stats.EntitiesHeld)
|
||||||
|
}
|
||||||
|
if len(stats.HeldAt) != 1 || stats.HeldAt[0].ID != "w1" {
|
||||||
|
t.Errorf("координаты удержанной версии %v", stats.HeldAt)
|
||||||
|
}
|
||||||
|
if got := storedRaw(t, st, "w1"); got != woWithRoute {
|
||||||
|
t.Error("маршрут пропал из витрины")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Усечённый маршрут ключа не теряет — множеств ключей мало.
|
||||||
|
func TestMergeУсечённыйМаршрутНеЗатираетСохранённый(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
st := open(t)
|
||||||
|
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||||
|
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woShortRoute))
|
||||||
|
|
||||||
|
if stats.EntitiesHeld != 1 {
|
||||||
|
t.Errorf("удержано %d, ожидалась 1", stats.EntitiesHeld)
|
||||||
|
}
|
||||||
|
if got := storedRaw(t, st, "w1"); got != woWithRoute {
|
||||||
|
t.Error("полный маршрут вытеснен усечённым")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Несравнимые наборы: у каждой версии есть содержание, которого нет у другой.
|
||||||
|
// Поля не объединяются, вместо этого — счётчик.
|
||||||
|
func TestMergeНесравнимыеНаборыНеОбъединяются(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
st := open(t)
|
||||||
|
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||||
|
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woIncomparable))
|
||||||
|
|
||||||
|
if stats.EntitiesHeld != 1 {
|
||||||
|
t.Errorf("удержано %d, ожидалась 1", stats.EntitiesHeld)
|
||||||
|
}
|
||||||
|
if got := storedRaw(t, st, "w1"); got != woWithRoute {
|
||||||
|
t.Error("несравнимая версия заместила сохранённую")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Хеш — детектор изменений: тренировка переприсылается каждой доставкой, пока
|
||||||
|
// не доедет маршрут, и 41 копия из 44 записи вызывать не должна.
|
||||||
|
func TestMergeПовторТойЖеТренировкиНеПишет(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
st := open(t)
|
||||||
|
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||||
|
|
||||||
|
// Тот же смысл, другой порядок ключей и дребезг литерала: каноническая
|
||||||
|
// форма обязана совпасть.
|
||||||
|
same := `{"name":"На улице Ходьба","id":"w1","totalEnergy":{"units":"kJ","qty":20.0},
|
||||||
|
"activeEnergy":[{"qty":10}],"route":[{"lat":1},{"lat":2},{"lat":3}]}`
|
||||||
|
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", same))
|
||||||
|
|
||||||
|
if stats.WorkoutsWritten != 0 {
|
||||||
|
t.Errorf("записано %d, ожидалось 0: содержимое то же", stats.WorkoutsWritten)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Три версии в разных порядках подачи: пункты правила, не зависящие от порядка
|
||||||
|
// свёртки, обязаны давать одно состояние. Конвенция требует перестановки трёх,
|
||||||
|
// а не пары: попарная свёртка уже давала нетранзитивную победу на точках.
|
||||||
|
func TestMergeПерестановкаТрёхВерсийДаётОдноСостояние(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type step struct {
|
||||||
|
d store.DeliveryRef
|
||||||
|
raw string
|
||||||
|
}
|
||||||
|
steps := []step{
|
||||||
|
{from(t, "d1", "2025-06-05T08:00:00Z"), woNoRouteNewValues},
|
||||||
|
{from(t, "d2", "2025-06-05T08:05:00Z"), woWithRoute},
|
||||||
|
{from(t, "d3", "2025-06-05T08:10:00Z"), woRicher},
|
||||||
|
}
|
||||||
|
orders := [][]int{{0, 1, 2}, {2, 1, 0}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {0, 2, 1}}
|
||||||
|
|
||||||
|
var want string
|
||||||
|
for i, order := range orders {
|
||||||
|
st := open(t)
|
||||||
|
for _, idx := range order {
|
||||||
|
mergeWorkouts(t, st, steps[idx].d, workout(t, "w1", steps[idx].raw))
|
||||||
|
}
|
||||||
|
got := storedRaw(t, st, "w1")
|
||||||
|
if i == 0 {
|
||||||
|
want = got
|
||||||
|
if want != woRicher {
|
||||||
|
t.Fatalf("победила не самая полная версия")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("порядок %v дал другое состояние", order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Порядок элементов в JSON-массиве нестабилен, поэтому две версии одного ключа
|
||||||
|
// внутри одной доставки не имеют права разрешаться «последним в массиве».
|
||||||
|
func TestMergeДвеВерсииВОдномТелеНеЗависятОтПорядка(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
d := from(t, "d1", "2025-06-05T08:00:00Z")
|
||||||
|
|
||||||
|
прямой := open(t)
|
||||||
|
mergeWorkouts(t, прямой, d, workout(t, "w1", woWithRoute), workout(t, "w1", woSameShapeNewValues))
|
||||||
|
|
||||||
|
обратный := open(t)
|
||||||
|
mergeWorkouts(t, обратный, d, workout(t, "w1", woSameShapeNewValues), workout(t, "w1", woWithRoute))
|
||||||
|
|
||||||
|
if storedRaw(t, прямой, "w1") != storedRaw(t, обратный, "w1") {
|
||||||
|
t.Error("исход зависит от порядка элементов в массиве секции")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Записи разных родов с одним идентификатором — разные записи: ключ пара, а не
|
||||||
|
// один id.
|
||||||
|
func TestMergeЗаписиРазныхРодовСОднимIDНеСталкиваются(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
st := open(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
mind := store.IncomingEntity{
|
||||||
|
ID: "e1", Kind: "stateOfMind",
|
||||||
|
Start: ts(t, "2025-06-05T18:00:00Z"),
|
||||||
|
End: ts(t, "2025-06-05T18:00:00Z"),
|
||||||
|
Raw: json.RawMessage(`{"id":"e1","kind":"momentary_emotion","valence":0.5}`),
|
||||||
|
}
|
||||||
|
ecg := store.IncomingEntity{
|
||||||
|
ID: "e1", Kind: "ecg",
|
||||||
|
Start: ts(t, "2025-06-05T19:00:00Z"),
|
||||||
|
End: ts(t, "2025-06-05T19:00:00Z"),
|
||||||
|
Raw: json.RawMessage(`{"id":"e1","classification":"sinusRhythm"}`),
|
||||||
|
}
|
||||||
|
if _, err := st.Merge(ctx, store.Incoming{Records: []store.IncomingEntity{mind, ecg}},
|
||||||
|
from(t, "d1", "2025-06-05T20:00:00Z")); err != nil {
|
||||||
|
t.Fatalf("слияние записей: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, kind := range []string{"stateOfMind", "ecg"} {
|
||||||
|
if _, err := st.Record(ctx, kind, "e1"); err != nil {
|
||||||
|
t.Errorf("запись рода %q не найдена: %v", kind, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отпечаток — единственный оракул сходимости. Витрины, совпадающие по часовым
|
||||||
|
// объектам, но разошедшиеся в тренировке, обязаны давать разные отпечатки.
|
||||||
|
func TestFingerprintРазличаетТренировки(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
build := func(raw string) string {
|
||||||
|
st := open(t)
|
||||||
|
in := store.Incoming{
|
||||||
|
Points: []store.IncomingPoint{point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)},
|
||||||
|
Workouts: []store.IncomingEntity{workout(t, "w1", raw)},
|
||||||
|
}
|
||||||
|
if _, err := st.Merge(ctx, in, from(t, "d1", "2025-06-05T08:00:00Z")); err != nil {
|
||||||
|
t.Fatalf("слияние: %v", err)
|
||||||
|
}
|
||||||
|
fp, err := st.Fingerprint(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("отпечаток: %v", err)
|
||||||
|
}
|
||||||
|
return fp
|
||||||
|
}
|
||||||
|
|
||||||
|
full, changed := build(woWithRoute), build(woSameShapeNewValues)
|
||||||
|
if full == changed {
|
||||||
|
t.Error("отпечатки совпали при разошедшемся содержимом тренировки")
|
||||||
|
}
|
||||||
|
if again := build(woWithRoute); again != full {
|
||||||
|
t.Error("отпечаток не воспроизводится на одном содержимом")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Длительность: ноль — законное измерение, а «не прислали» обязано быть
|
||||||
|
// отличимо от него.
|
||||||
|
func TestWorkoutДлительностьОтличаетНольОтОтсутствия(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
st := open(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
zero := 0.0
|
||||||
|
withZero := workout(t, "w-zero", `{"id":"w-zero","qty":1}`)
|
||||||
|
withZero.Duration = &zero
|
||||||
|
withNone := workout(t, "w-none", `{"id":"w-none","qty":1}`)
|
||||||
|
|
||||||
|
if _, err := st.Merge(ctx, store.Incoming{Workouts: []store.IncomingEntity{withZero, withNone}},
|
||||||
|
from(t, "d1", "2025-06-05T08:00:00Z")); err != nil {
|
||||||
|
t.Fatalf("слияние: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w, err := st.Workout(ctx, "w-zero")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("чтение: %v", err)
|
||||||
|
}
|
||||||
|
if w.Duration == nil || *w.Duration != 0 {
|
||||||
|
t.Errorf("нулевая длительность потерялась: %v", w.Duration)
|
||||||
|
}
|
||||||
|
w, err = st.Workout(ctx, "w-none")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("чтение: %v", err)
|
||||||
|
}
|
||||||
|
if w.Duration != nil {
|
||||||
|
t.Errorf("отсутствие длительности стало значением %v", *w.Duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Содержимое сущности хранится дословно: побайтовый круг «запись → чтение».
|
||||||
|
func TestWorkoutСодержимоеХранитсяДословно(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
st := open(t)
|
||||||
|
// Литералы, которые теряет любая пересборка через разобранные значения:
|
||||||
|
// `1.0`, целое больше 2^53, дробь длиннее двенадцати значащих цифр, а также
|
||||||
|
// символы, которые json.Marshal экранирует по умолчанию.
|
||||||
|
const raw = `{"id":"w9","route":[{"lat":1.0,"big":9007199254740993,"p":0.123456789012345678}],
|
||||||
|
"note":"a<b&c>d","isIndoor":false}`
|
||||||
|
|
||||||
|
if _, err := st.Merge(context.Background(),
|
||||||
|
store.Incoming{Workouts: []store.IncomingEntity{workout(t, "w9", raw)}},
|
||||||
|
from(t, "d1", "2025-06-05T08:00:00Z")); err != nil {
|
||||||
|
t.Fatalf("слияние: %v", err)
|
||||||
|
}
|
||||||
|
if got := storedRaw(t, st, "w9"); got != raw {
|
||||||
|
t.Errorf("содержимое изменилось при хранении:\nбыло %s\nстало %s", raw, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Провенанс нужен не отчётности: по нему разрешается тай-брейк, и без него
|
||||||
|
// запись WARN об удержанной версии не связать с телом в архиве.
|
||||||
|
func TestWorkoutНесётПровенанс(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
st := open(t)
|
||||||
|
mergeWorkouts(t, st, from(t, "d7", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
|
||||||
|
|
||||||
|
w, err := st.Workout(context.Background(), "w1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("чтение: %v", err)
|
||||||
|
}
|
||||||
|
if w.Delivery != "d7" {
|
||||||
|
t.Errorf("провенанс %q, ожидался d7", w.Delivery)
|
||||||
|
}
|
||||||
|
if w.Start.IsZero() || w.End.Before(w.Start) {
|
||||||
|
t.Errorf("интервал заголовка неверен: %v — %v", w.Start, w.End)
|
||||||
|
}
|
||||||
|
if w.OffsetSeconds != 3*3600 {
|
||||||
|
t.Errorf("офсет %d, ожидался 10800", w.OffsetSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Внутри одной доставки «сохранённой» версии не существует — есть только
|
||||||
|
// порядок элементов в JSON-массиве, а он нестабилен. Ни исход, ни счётчик не
|
||||||
|
// имеют права от него зависеть.
|
||||||
|
func TestMergeНесравнимыеВерсииВОдномТелеНеЗависятОтПорядка(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
d := from(t, "d1", "2025-06-05T08:00:00Z")
|
||||||
|
const withRoute = `{"id":"w1","route":[{"lat":1}],"stepCount":{"qty":9}}`
|
||||||
|
const withFlights = `{"id":"w1","route":[{"lat":1}],"flightsClimbed":{"qty":3}}`
|
||||||
|
|
||||||
|
прямой := open(t)
|
||||||
|
a := mergeWorkouts(t, прямой, d, workout(t, "w1", withRoute), workout(t, "w1", withFlights))
|
||||||
|
|
||||||
|
обратный := open(t)
|
||||||
|
b := mergeWorkouts(t, обратный, d, workout(t, "w1", withFlights), workout(t, "w1", withRoute))
|
||||||
|
|
||||||
|
if storedRaw(t, прямой, "w1") != storedRaw(t, обратный, "w1") {
|
||||||
|
t.Error("исход зависит от порядка элементов в массиве секции")
|
||||||
|
}
|
||||||
|
if a.EntitiesHeld != b.EntitiesHeld {
|
||||||
|
t.Errorf("счётчик зависит от порядка: %d против %d", a.EntitiesHeld, b.EntitiesHeld)
|
||||||
|
}
|
||||||
|
if a.EntitiesHeld == 0 {
|
||||||
|
t.Error("две версии с разным содержанием в одном теле остались незамеченными")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отпечаток обязан различать состояния, а не только содержимое: составной ключ
|
||||||
|
// записи, склеенный до взятия длины, даёт (`a`, `b/c`) = (`a/b`, `c`).
|
||||||
|
func TestFingerprintРазличаетСоставнойКлючЗаписи(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
build := func(kind, id string) string {
|
||||||
|
st := open(t)
|
||||||
|
rec := store.IncomingEntity{
|
||||||
|
ID: id, Kind: kind,
|
||||||
|
Start: ts(t, "2025-06-05T18:00:00Z"),
|
||||||
|
End: ts(t, "2025-06-05T18:00:00Z"),
|
||||||
|
Raw: json.RawMessage(`{"id":"x","valence":0.5}`),
|
||||||
|
}
|
||||||
|
if _, err := st.Merge(ctx, store.Incoming{Records: []store.IncomingEntity{rec}},
|
||||||
|
from(t, "d1", "2025-06-05T08:00:00Z")); err != nil {
|
||||||
|
t.Fatalf("слияние: %v", err)
|
||||||
|
}
|
||||||
|
fp, err := st.Fingerprint(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("отпечаток: %v", err)
|
||||||
|
}
|
||||||
|
return fp
|
||||||
|
}
|
||||||
|
|
||||||
|
if build("a", "b/c") == build("a/b", "c") {
|
||||||
|
t.Error("два разных состояния витрины дали один отпечаток: составной ключ склеен до взятия длины")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,3 +88,77 @@ func TestMigrationПрежниеParsedСтановятсяPending(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Миграция 00007 исполняет правило «покрыли секцию — пересверните»: список
|
||||||
|
// непокрытых ключей это снимок покрытия на момент свёртки, и доставки,
|
||||||
|
// свёрнутые до того, как workouts и stateOfMind стали покрытыми, остались бы
|
||||||
|
// `partial` со старым списком навсегда — а ретеншен вечно щадил бы их тела.
|
||||||
|
//
|
||||||
|
// Перевод ТОЧЕЧНЫЙ: доставка, у которой непокрыта только `ecg`, пересворачивать
|
||||||
|
// нечего, и трогать её значило бы гонять весь архив на каждую новую секцию.
|
||||||
|
func TestMigrationПокрытыеСекцииВозвращаютсяВОчередь(t *testing.T) {
|
||||||
|
db, err := sqlx.Connect("sqlite", dsn(filepath.Join(t.TempDir(), "healthlog.db")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("открытие базы: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
sub, err := fs.Sub(migrationsFS, "migrations")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("миграции: %v", err)
|
||||||
|
}
|
||||||
|
p, err := goose.NewProvider(goose.DialectSQLite3, db.DB, sub)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("провайдер: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := p.UpTo(ctx, 6); err != nil {
|
||||||
|
t.Fatalf("миграция до 6: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const insert = `
|
||||||
|
INSERT INTO delivery (id, received_at, automation_name, automation_id,
|
||||||
|
aggregation, period, session_id, bytes, sha256, raw_path,
|
||||||
|
parse_status, points, uncovered_sections)
|
||||||
|
VALUES (?, '2025-06-05T10:00:00Z', '', '', '', '', '', 0, '-', '-', ?, 0, ?)`
|
||||||
|
cases := []struct {
|
||||||
|
id string
|
||||||
|
status string
|
||||||
|
uncovered string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"d-workouts", ParsePartial, `["workouts"]`, ParsePending},
|
||||||
|
{"d-mind", ParsePartial, `["stateOfMind"]`, ParsePending},
|
||||||
|
{"d-both", ParsePartial, `["stateOfMind","workouts"]`, ParsePending},
|
||||||
|
{"d-mixed", ParsePartial, `["ecg","workouts"]`, ParsePending},
|
||||||
|
// Ничего из ставшего покрытым: трогать нечего.
|
||||||
|
{"d-ecg", ParsePartial, `["ecg"]`, ParsePartial},
|
||||||
|
// Подстрока имени секции — не имя секции: отбор идёт по элементу
|
||||||
|
// массива, иначе чужое тело управляло бы тем, что мы пересворачиваем.
|
||||||
|
{"d-lookalike", ParsePartial, `["myworkoutsx"]`, ParsePartial},
|
||||||
|
// Статус `failed` возвращает только пересборка, а `parsed` этой
|
||||||
|
// миграцией не трогается: у него пустой список непокрытых.
|
||||||
|
{"d-failed", ParseFailed, `["workouts"]`, ParseFailed},
|
||||||
|
{"d-parsed", ParseDone, `[]`, ParseDone},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if _, err := db.ExecContext(ctx, insert, c.id, c.status, c.uncovered); err != nil {
|
||||||
|
t.Fatalf("вставка %s: %v", c.id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := p.UpTo(ctx, 7); err != nil {
|
||||||
|
t.Fatalf("миграция до 7: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
var got string
|
||||||
|
if err := db.GetContext(ctx, &got, `SELECT parse_status FROM delivery WHERE id = ?`, c.id); err != nil {
|
||||||
|
t.Fatalf("чтение %s: %v", c.id, err)
|
||||||
|
}
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("%s: статус %q, ожидался %q (непокрытые %s)", c.id, got, c.want, c.uncovered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Вторая единица хранения витрины: сущность с собственным идентификатором.
|
||||||
|
-- Часовой объект ей не подходит — у неё есть естественный ключ, она редка (за
|
||||||
|
-- двое суток потока две тренировки и две записи состояния разума при 44 и 52
|
||||||
|
-- доставленных копиях), и группировать её по часам незачем.
|
||||||
|
--
|
||||||
|
-- Таблицы две, а не одна с колонкой рода: у тренировки есть заголовок, по
|
||||||
|
-- которому идёт выборка (имя, интервал, длительность), а у записи его нет.
|
||||||
|
-- Общая таблица либо теряла бы заголовок, либо держала колонки, пустые у пяти
|
||||||
|
-- родов из шести.
|
||||||
|
CREATE TABLE workout (
|
||||||
|
-- Идентификатор из HealthKit. Приходит из тела и ограничен по длине
|
||||||
|
-- разбором: уезжает и в ключ, и в записи лога.
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
|
||||||
|
-- Имя как прислал HAE, локализованное («В помещении Ходьба» — машинная
|
||||||
|
-- калька с Indoor Walk). Хранится дословно; стабильный код HealthKit
|
||||||
|
-- припишет задача словаря категориальных значений.
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
|
||||||
|
-- Интервал в UTC, RFC 3339. Конец, которого нет или который не читается,
|
||||||
|
-- равен началу: ключ — id, схлопывать координаты нечем, а истина остаётся
|
||||||
|
-- в payload.
|
||||||
|
start_utc TEXT NOT NULL,
|
||||||
|
end_utc TEXT NOT NULL,
|
||||||
|
-- Смещение зоны НАЧАЛА: колонка одна, а тренировка через смену зоны дала
|
||||||
|
-- бы два разных.
|
||||||
|
tz_offset INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Длительность в секундах, как прислал HAE. NULL означает «источник не
|
||||||
|
-- прислал»: ноль — законная длительность, и потребитель, сложивший
|
||||||
|
-- столбец, иначе не отличил бы одно от другого. Не вычисляется из
|
||||||
|
-- интервала — HAE шлёт 91.746 при интервале в 91 секунду.
|
||||||
|
duration_sec REAL,
|
||||||
|
|
||||||
|
-- Тренировка целиком исходными байтами, сжатая gzip: заголовок, маршрут,
|
||||||
|
-- внутренние ряды и сводки. Маршрут — 95% веса (190 КБ из 199,6 у
|
||||||
|
-- десятиминутной прогулки), а такой JSON жмётся примерно в 25 раз.
|
||||||
|
-- Внутрь средствами SQL не заглянуть — та же плата, что у bucket.payload.
|
||||||
|
payload BLOB NOT NULL,
|
||||||
|
-- Хеш канонической формы содержимого: детектор изменений, а не ключ.
|
||||||
|
-- Тренировка переприсылается каждой доставкой, пока не доедет маршрут: 44
|
||||||
|
-- копии на живом архиве дают три различных содержимых.
|
||||||
|
content_hash TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Провенанс: доставка, ЧЬЯ ВЕРСИЯ лежит сейчас, и её метка приёма. Не
|
||||||
|
-- отчётность: по паре (received_at, id) разрешается тай-брейк между
|
||||||
|
-- версиями равной полноты. «Побеждает приехавшая» было бы функцией порядка
|
||||||
|
-- свёртки, а он порядку журнала не равен — доставка с более ранней меткой,
|
||||||
|
-- свёрнутая позже, вернула бы витрину к недосчитанной версии, и пересборка
|
||||||
|
-- разошлась бы с живым приёмом молча.
|
||||||
|
-- Без DEFAULT намеренно: единственный писатель заполняет обе колонки
|
||||||
|
-- всегда, а умолчание превратило бы его дефект из отказа вставки в тихо
|
||||||
|
-- неверный исход — строка с пустой меткой оказалась бы «самой ранней в
|
||||||
|
-- журнале», и её затирала бы любая приехавшая версия.
|
||||||
|
delivery_id TEXT NOT NULL,
|
||||||
|
delivery_received_at TEXT NOT NULL,
|
||||||
|
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Основной запрос трекера — «заголовки тренировок за период».
|
||||||
|
CREATE INDEX workout_start_utc ON workout (start_utc);
|
||||||
|
|
||||||
|
CREATE TABLE record (
|
||||||
|
-- Род — верхнеуровневый ключ секции HAE ДОСЛОВНО (`stateOfMind`, не
|
||||||
|
-- `state_of_mind`): инвариант «форма Apple не транслируется» относится и к
|
||||||
|
-- именам секций.
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Метка события в UTC и смещение исходной зоны. У stateOfMind HAE шлёт
|
||||||
|
-- RFC 3339 в UTC, поэтому смещение там всегда 0 — это значит «источник
|
||||||
|
-- прислал UTC», а не «человек был в Гринвиче»: местной зоны у секции в
|
||||||
|
-- потоке нет вовсе.
|
||||||
|
ts_utc TEXT NOT NULL,
|
||||||
|
tz_offset INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
payload BLOB NOT NULL,
|
||||||
|
content_hash TEXT NOT NULL,
|
||||||
|
|
||||||
|
delivery_id TEXT NOT NULL,
|
||||||
|
delivery_received_at TEXT NOT NULL,
|
||||||
|
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Ключ — ПАРА, а не один id. Собственный id наблюдался живьём только у
|
||||||
|
-- stateOfMind, где он UUID HealthKit; форма идентификатора остальных пяти
|
||||||
|
-- секций не наблюдалась никем, и короткий несквозной id в двух разных
|
||||||
|
-- секциях затёр бы одну запись другой молча. Пара стоит ноль: запросы к
|
||||||
|
-- записям всегда идут с родом.
|
||||||
|
PRIMARY KEY (kind, id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- «Записи такого-то рода за период» — единственная форма запроса к таблице.
|
||||||
|
CREATE INDEX record_kind_ts ON record (kind, ts_utc);
|
||||||
|
|
||||||
|
-- Покрыли секцию — пересверните. Список непокрытых ключей это снимок покрытия
|
||||||
|
-- на момент свёртки: доставки, свёрнутые до того, как workouts и stateOfMind
|
||||||
|
-- стали покрытыми, остались бы partial со старым списком, и ретеншен вечно
|
||||||
|
-- щадил бы тела, которые больше ничего не хранят сверх витрины.
|
||||||
|
--
|
||||||
|
-- Отбор по ЭЛЕМЕНТУ массива, а не по подстроке тела: имя секции приходит из
|
||||||
|
-- чужого тела, и LIKE '%workouts%' поймал бы ключ, лишь содержащий эту
|
||||||
|
-- подстроку. Перевод точечный, а не «все partial»: доставка с непокрытой ecg
|
||||||
|
-- пересворачивать нечего.
|
||||||
|
UPDATE delivery
|
||||||
|
SET parse_status = 'pending'
|
||||||
|
WHERE parse_status = 'partial'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM json_each(delivery.uncovered_sections)
|
||||||
|
WHERE json_each.value IN ('workouts', 'stateOfMind')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- Строки, переведённые в pending, Down обратно не возвращает: какими они были,
|
||||||
|
-- восстановить неоткуда, а pending консервативен — ретеншен его не трогает.
|
||||||
|
DROP INDEX record_kind_ts;
|
||||||
|
DROP TABLE record;
|
||||||
|
DROP INDEX workout_start_utc;
|
||||||
|
DROP TABLE workout;
|
||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
// каждом глубоком проходе, и настоящий отказ правила слияния становился
|
// каждом глубоком проходе, и настоящий отказ правила слияния становился
|
||||||
// неотличим от нормы — при том что счётчик перезаписей объявлен единственным
|
// неотличим от нормы — при том что счётчик перезаписей объявлен единственным
|
||||||
// наблюдением за этим правилом.
|
// наблюдением за этим правилом.
|
||||||
func TestMergePointsДребезгНеСчитаетсяСтолкновением(t *testing.T) {
|
func TestMergeДребезгНеСчитаетсяСтолкновением(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cases := map[string][2]string{
|
cases := map[string][2]string{
|
||||||
@@ -41,10 +41,10 @@ func TestMergePointsДребезгНеСчитаетсяСтолкновение
|
|||||||
first := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", pair[0])
|
first := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", pair[0])
|
||||||
second := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", pair[1])
|
second := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", pair[1])
|
||||||
|
|
||||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{first}, "d1"); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{first}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||||
t.Fatalf("первое слияние: %v", err)
|
t.Fatalf("первое слияние: %v", err)
|
||||||
}
|
}
|
||||||
stats, err := st.MergePoints(ctx, []store.IncomingPoint{second}, "d2")
|
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{second}}, store.DeliveryRef{ID: "d2"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("второе слияние: %v", err)
|
t.Fatalf("второе слияние: %v", err)
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ func TestMergePointsДребезгНеСчитаетсяСтолкновение
|
|||||||
// Настоящее столкновение обязано оставить след с координатами объекта: одно
|
// Настоящее столкновение обязано оставить след с координатами объекта: одно
|
||||||
// число `overwrites` не говорит, какая метрика и какой час пострадали, и
|
// число `overwrites` не говорит, какая метрика и какой час пострадали, и
|
||||||
// расследовать перезапись по нему нечем.
|
// расследовать перезапись по нему нечем.
|
||||||
func TestMergePointsСтолкновениеОставляетКоординаты(t *testing.T) {
|
func TestMergeСтолкновениеОставляетКоординаты(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
st := open(t)
|
st := open(t)
|
||||||
@@ -74,10 +74,10 @@ func TestMergePointsСтолкновениеОставляетКоординат
|
|||||||
poor := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
poor := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||||
`{"Avg":61}`)
|
`{"Avg":61}`)
|
||||||
|
|
||||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{rich}, "d1"); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{rich}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||||
t.Fatalf("первое слияние: %v", err)
|
t.Fatalf("первое слияние: %v", err)
|
||||||
}
|
}
|
||||||
stats, err := st.MergePoints(ctx, []store.IncomingPoint{poor}, "d2")
|
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{poor}}, store.DeliveryRef{ID: "d2"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("второе слияние: %v", err)
|
t.Fatalf("второе слияние: %v", err)
|
||||||
}
|
}
|
||||||
@@ -98,7 +98,7 @@ func TestMergePointsСтолкновениеОставляетКоординат
|
|||||||
// экранирует `&`, `<` и `>` внутри содержимого — порчи значений это не даёт, но
|
// экранирует `&`, `<` и `>` внутри содержимого — порчи значений это не даёт, но
|
||||||
// обещание перестаёт быть правдой, а сравнение байтов при следующей доставке
|
// обещание перестаёт быть правдой, а сравнение байтов при следующей доставке
|
||||||
// той же точки начинает промахиваться навсегда.
|
// той же точки начинает промахиваться навсегда.
|
||||||
func TestMergePointsХранитУгловыеСкобкиИАмперсанд(t *testing.T) {
|
func TestMergeХранитУгловыеСкобкиИАмперсанд(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
st := open(t)
|
st := open(t)
|
||||||
@@ -107,7 +107,7 @@ func TestMergePointsХранитУгловыеСкобкиИАмперсанд(t
|
|||||||
raw := `{"qty":1,"source":"Anton & Co <iPhone> \"x\""}`
|
raw := `{"qty":1,"source":"Anton & Co <iPhone> \"x\""}`
|
||||||
in := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", raw)
|
in := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", raw)
|
||||||
|
|
||||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{in}, "d1"); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{in}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||||
t.Fatalf("слияние: %v", err)
|
t.Fatalf("слияние: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ func TestMergePointsХранитУгловыеСкобкиИАмперсанд(t
|
|||||||
// Смена единиц не имеет права молча переподписать уже сохранённые точки:
|
// Смена единиц не имеет права молча переподписать уже сохранённые точки:
|
||||||
// внутри точки единиц нет, и у ранних точек не остаётся ничего, по чему их
|
// внутри точки единиц нет, и у ранних точек не остаётся ничего, по чему их
|
||||||
// единицы восстановимы. Правило «первое непустое побеждает» плюс счётчик.
|
// единицы восстановимы. Правило «первое непустое побеждает» плюс счётчик.
|
||||||
func TestMergePointsСменаЕдиницНеПерезаписываетМолча(t *testing.T) {
|
func TestMergeСменаЕдиницНеПерезаписываетМолча(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
st := open(t)
|
st := open(t)
|
||||||
@@ -148,10 +148,10 @@ func TestMergePointsСменаЕдиницНеПерезаписываетМол
|
|||||||
mi.End = mi.Start
|
mi.End = mi.Start
|
||||||
mi.Raw = []byte(`{"qty":2}`)
|
mi.Raw = []byte(`{"qty":2}`)
|
||||||
|
|
||||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{km}, "d1"); err != nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{km}}, store.DeliveryRef{ID: "d1"}); err != nil {
|
||||||
t.Fatalf("первое слияние: %v", err)
|
t.Fatalf("первое слияние: %v", err)
|
||||||
}
|
}
|
||||||
stats, err := st.MergePoints(ctx, []store.IncomingPoint{mi}, "d2")
|
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{mi}}, store.DeliveryRef{ID: "d2"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("второе слияние: %v", err)
|
t.Fatalf("второе слияние: %v", err)
|
||||||
}
|
}
|
||||||
@@ -173,14 +173,14 @@ func TestMergePointsСменаЕдиницНеПерезаписываетМол
|
|||||||
// доставки схлопываются, и счётчик присланных систематически завышал бы
|
// доставки схлопываются, и счётчик присланных систематически завышал бы
|
||||||
// содержимое витрины — расхождение «прислали 1000, лежит 700» было бы невидимо
|
// содержимое витрины — расхождение «прислали 1000, лежит 700» было бы невидимо
|
||||||
// ровно тогда, когда точки начнут теряться по-настоящему.
|
// ровно тогда, когда точки начнут теряться по-настоящему.
|
||||||
func TestMergePointsСчитаетСохранённые(t *testing.T) {
|
func TestMergeСчитаетСохранённые(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
st := open(t)
|
st := open(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
p := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)
|
p := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)
|
||||||
stats, err := st.MergePoints(ctx, []store.IncomingPoint{p, p, p}, "d1")
|
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p, p, p}}, store.DeliveryRef{ID: "d1"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("слияние: %v", err)
|
t.Fatalf("слияние: %v", err)
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,7 @@ func TestMergePointsСчитаетСохранённые(t *testing.T) {
|
|||||||
t.Errorf("сохранено %d точек, ожидалась 1 (три точных повтора)", stats.Stored)
|
t.Errorf("сохранено %d точек, ожидалась 1 (три точных повтора)", stats.Stored)
|
||||||
}
|
}
|
||||||
|
|
||||||
stats, err = st.MergePoints(ctx, []store.IncomingPoint{p}, "d2")
|
stats, err = st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p}}, store.DeliveryRef{ID: "d2"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("повтор: %v", err)
|
t.Fatalf("повтор: %v", err)
|
||||||
}
|
}
|
||||||
@@ -201,7 +201,7 @@ func TestMergePointsСчитаетСохранённые(t *testing.T) {
|
|||||||
// оставляет частичного состояния — а значит и не зависит от порядка обхода.
|
// оставляет частичного состояния — а значит и не зависит от порядка обхода.
|
||||||
// Раньше транзакция была на объект, и восемь прогонов одной доставки давали
|
// Раньше транзакция была на объект, и восемь прогонов одной доставки давали
|
||||||
// семь разных наборов записанных объектов.
|
// семь разных наборов записанных объектов.
|
||||||
func TestMergePointsОтказНеОставляетЧастиОбъектов(t *testing.T) {
|
func TestMergeОтказНеОставляетЧастиОбъектов(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
st := open(t)
|
st := open(t)
|
||||||
@@ -218,7 +218,7 @@ func TestMergePointsОтказНеОставляетЧастиОбъектов(t
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
if _, err := st.MergePoints(ctx, in, "d1"); err == nil {
|
if _, err := st.Merge(ctx, store.Incoming{Points: in}, store.DeliveryRef{ID: "d1"}); err == nil {
|
||||||
t.Fatal("слияние на отменённом контексте прошло успешно")
|
t.Fatal("слияние на отменённом контексте прошло успешно")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-02
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Разбор покрывает одну секцию тела — `metrics`. Остальное перечисляется в
|
||||||
|
`delivery.uncovered_sections`, доставка получает `partial`, тело живёт в архиве.
|
||||||
|
Замер по 118 доставкам архива: `metrics` — 65 доставок, `workouts` — 27,
|
||||||
|
`stateOfMind` — 26; ни одна доставка не несла двух секций сразу.
|
||||||
|
|
||||||
|
Тренировка и состояние разума устроены иначе, чем метрика, и это не стилистика:
|
||||||
|
|
||||||
|
- у них есть **собственный `id`** (UUID из HealthKit) — координатный ключ
|
||||||
|
`метрика + слой + начало + конец` им не нужен;
|
||||||
|
- они **редки**: за двое суток потока — 2 разных тренировки и 2 разных записи
|
||||||
|
состояния разума, при 44 и 52 доставленных копиях соответственно;
|
||||||
|
- у них **нет слоя**: подробности выгрузки у этих секций в интерфейсе HAE не
|
||||||
|
бывает, есть только глубина окна;
|
||||||
|
- тренировка **тяжёлая**: маршрут — 95% её веса (190 КБ из 199,6 КБ у
|
||||||
|
десятиминутной прогулки), и приезжает она повторно, пока маршрут не доедет.
|
||||||
|
|
||||||
|
Замер поведения при переприсылке (тот же архив, группировка по `id`):
|
||||||
|
|
||||||
|
```
|
||||||
|
тренировка A 26 копий 3 различных содержимых поля росли, не убывали
|
||||||
|
тренировка B 18 копий 1 содержимое маршрут с первой копии
|
||||||
|
stateOfMind 26 и 26 копий, по 1 содержимому каждая
|
||||||
|
```
|
||||||
|
|
||||||
|
Что именно менялось у тренировки A между версиями:
|
||||||
|
|
||||||
|
```
|
||||||
|
версия 0 → 1 +stepCadence, +stepCount, изменилось значение activeEnergy
|
||||||
|
версия 1 → 2 набор полей тот же, изменились totalEnergy и basalEnergy
|
||||||
|
```
|
||||||
|
|
||||||
|
То есть тренировка досчитывается задним числом ровно так же, как минутное ведро
|
||||||
|
(находка 10), и при этом набор полей за весь корпус ни разу не уменьшился.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Тренировка лежит в витрине целиком, вместе с маршрутом и внутренними рядами,
|
||||||
|
дословно и без интерпретации.
|
||||||
|
- Состояние разума лежит записями — секция, которой нет в экспорте Apple, больше
|
||||||
|
не зависит от того, что тело не удалили.
|
||||||
|
- Свёртка остаётся детерминированной: `reindex` даёт то же состояние, что живой
|
||||||
|
приём, и это проверяется отпечатком, а не «числом строк».
|
||||||
|
- Правило «при столкновении выигрывает более полная версия» продолжает
|
||||||
|
действовать и для сущности с собственным `id`.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- **Отдача наружу.** Read API в проекте нет вовсе; форма конверта, выбор слоя и
|
||||||
|
предел размера ответа проектируются задачей `read-api-tochki`. Два эндпоинта,
|
||||||
|
введённые раньше конверта, задали бы контракт мимоходом.
|
||||||
|
- **Секции, которых поток не приносил** (`ecg`, `symptoms`, `cycleTracking`,
|
||||||
|
`medications`, `heartRateNotifications`). Модель под них закладывается —
|
||||||
|
таблица `record` ключуется родом секции, — но разбор не пишется вслепую: их
|
||||||
|
формы никто не видел, а задача `proverka-novyh-sekcij` существует ровно про
|
||||||
|
момент, когда они появятся.
|
||||||
|
- **Разворачивание маршрута** в таблицу точек — отдельная идея беклога, у неё нет
|
||||||
|
клиента.
|
||||||
|
- **Словарь категориальных значений** (`name` тренировки — «В помещении Ходьба»,
|
||||||
|
машинная калька) — отдельная задача; здесь строка хранится дословно.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Две таблицы, а не одна с колонкой рода
|
||||||
|
|
||||||
|
`workout` и `record` разведены, как и записано в `docs/architecture.md`.
|
||||||
|
|
||||||
|
Общая таблица `entity(kind, id, …)` выглядит экономнее и хуже по существу: у
|
||||||
|
тренировки есть заголовок, который нужен запросом «что было за период» —
|
||||||
|
`name`, `start`, `end`, `duration`, — а у записи состояния разума его нет.
|
||||||
|
Общая таблица либо теряет заголовок (тогда список тренировок требует разжатия
|
||||||
|
каждого блоба), либо заводит колонки, пустые у пяти родов из шести.
|
||||||
|
|
||||||
|
Отвергнуто и обратное — таблица на каждый род секции: шесть почти одинаковых
|
||||||
|
таблиц, и каждая новая секция требует миграции. `record` ключуется родом, и
|
||||||
|
новая секция добавляется одной строкой в множество покрытых имён.
|
||||||
|
|
||||||
|
### 2. Ключ `record` — `(kind, id)`, а не один `id`
|
||||||
|
|
||||||
|
Отклонение от схемы, набросанной в `architecture.md` (`record(id PK, kind, …)`),
|
||||||
|
и оно намеренное. У `stateOfMind` `id` — настоящий UUID HealthKit, но остальные
|
||||||
|
пять секций живьём не видели никто: форма их идентификатора неизвестна, и
|
||||||
|
короткий несквозной `id` в двух разных секциях молча затёр бы одну запись
|
||||||
|
другой. Пара стоит ноль (запросы к `record` всегда идут с родом: `GET
|
||||||
|
/records/{kind}`) и снимает целый класс.
|
||||||
|
|
||||||
|
Ключ `workout` — `id`: род у него один.
|
||||||
|
|
||||||
|
### 3. Сущность заменяется целиком; побеждает не последняя, а не теряющая полей
|
||||||
|
|
||||||
|
Центральное решение задачи. Тренировка «перезаписывается» (беклог,
|
||||||
|
`architecture.md`), но инвариант проекта гласит «при столкновении выигрывает
|
||||||
|
более полная точка, а не последняя пришедшая». Развилку решает замер выше.
|
||||||
|
|
||||||
|
Правило:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. каноническая форма совпала с сохранённой → записи нет (хеш-детектор)
|
||||||
|
2. приехавшая несёт всё, что сохранённая, и
|
||||||
|
сверх того → приехавшая замещает целиком
|
||||||
|
3. приехавшая теряет содержание сохранённой → остаётся сохранённая,
|
||||||
|
счётчик + WARN
|
||||||
|
4. содержание сравнимо (наборы равны) → версия из БОЛЕЕ ПОЗДНЕЙ
|
||||||
|
доставки журнала
|
||||||
|
5. наборы несравнимы → остаётся сохранённая,
|
||||||
|
счётчик + WARN
|
||||||
|
```
|
||||||
|
|
||||||
|
**«Теряет содержание» считается по множеству ключей, а не по `canon.Relate`.**
|
||||||
|
Это единственная деталь, где реализация не может переиспользовать правило точек
|
||||||
|
как есть, и цена ошибки здесь — маршрут. Проверено выполненной командой на копии
|
||||||
|
пакета `canon`:
|
||||||
|
|
||||||
|
```
|
||||||
|
сохранённая vs обеднённая, значения общих полей те же : superset
|
||||||
|
сохранённая vs обеднённая, значения общих полей иные : equal
|
||||||
|
сохранённая vs усечённый маршрут (2 точки → 1) : equal
|
||||||
|
```
|
||||||
|
|
||||||
|
`canon.Fields.Relate` гасит отношение включения до `equal`, когда значения
|
||||||
|
общих содержательных ключей разошлись, — и это верно для точки (надмножество
|
||||||
|
имён при других значениях означает другое измерение), но неверно для сущности:
|
||||||
|
замер выше говорит, что между версиями тренировки значения меняются **всегда**.
|
||||||
|
То есть настоящая обеднённая версия пришла бы с изменёнными значениями,
|
||||||
|
получила бы `equal` и заместила бы сохранённую целиком, а тест на наивной
|
||||||
|
фикстуре (значения не тронуты) остался бы зелёным.
|
||||||
|
|
||||||
|
Поэтому в `canon` заводится вторая, явная операция — сравнение **множеств
|
||||||
|
содержательных ключей** без условия о совпадении значений, поверх уже
|
||||||
|
существующего внутреннего `relateKeys`. Именно в `canon`, а не в `store`:
|
||||||
|
пакет заведён ради единственной реализации сравнения, и вторая копия разошлась
|
||||||
|
бы с первой молча.
|
||||||
|
|
||||||
|
Полнота меряется **верхним уровнем** ключей и длиной верхнеуровневых массивов.
|
||||||
|
Второе добавлено намеренно: усечённый маршрут (3 точки вместо 593) ключа не
|
||||||
|
теряет, поэтому одних множеств мало, а маршрут — 95% веса тренировки. Досчёт
|
||||||
|
ряды удлиняет, а не укорачивает, так что укорачивание — законный сигнал
|
||||||
|
«приехало меньше». Предел правила назван вслух: сокращение **внутри** элемента
|
||||||
|
ряда (точка маршрута потеряла `altitude`) не ловится ничем.
|
||||||
|
|
||||||
|
**Тай-брейк при равных наборах — позиция доставки в журнале, а не порядок
|
||||||
|
свёртки.** У точки при равной полноте исход решает порядок канонических форм
|
||||||
|
(`canon.Less`) — тай-брейк, который намеренно не выбран, пока не измерен род
|
||||||
|
агрегации. Приложи его к тренировке — и на наших же данных версия 1 → 2 (набор
|
||||||
|
полей тот же, досчитаны `totalEnergy` и `basalEnergy`) осталась бы на
|
||||||
|
произвольной из двух **навсегда**: тренировка замерла бы с недосчитанной
|
||||||
|
энергией. Причина расхождения содержательная: у точки на одной координате
|
||||||
|
законно встречаются два разных измерения (разные устройства,
|
||||||
|
пересэмплирование), и предпочитать позднее нет оснований; у сущности `id` —
|
||||||
|
идентичность одного объекта HealthKit, и вторая версия есть тот же объект,
|
||||||
|
пересчитанный источником.
|
||||||
|
|
||||||
|
Отвергнуто и напрашивавшееся «побеждает приехавшая»: приехавшая — это функция
|
||||||
|
**порядка свёртки**, а он не равен порядку журнала. Спека приёма говорит прямо,
|
||||||
|
что воркер сворачивает в порядке `(received_at, id)` только среди **видимых**
|
||||||
|
ему доставок, а абсолютного порядка при конкурентных приёмах не обещает
|
||||||
|
(`docs/architecture.md`, «Предел порядка назван вслух»; открытый блокер
|
||||||
|
`poryadok-zhurnala-na-priyome.md`). Доставка с более ранней меткой, свёрнутая
|
||||||
|
позже, вернула бы витрину к недосчитанной версии — и `reindex` разошёлся бы с
|
||||||
|
живым приёмом **молча**, в содержимом тренировки. Поэтому сущность несёт
|
||||||
|
провенанс — `delivery_id` и `received_at` своей доставки, — а тай-брейк
|
||||||
|
сравнивает пару `(received_at, id)`. Тогда исход при равных наборах зависит
|
||||||
|
только от журнала, а не от того, кто раньше добрался до базы.
|
||||||
|
|
||||||
|
Провенанс нужен и сам по себе: у часового объекта он обязателен («провенанс для
|
||||||
|
разбора слияний»), а `WARN` об удержанной обеднённой версии без него не связать
|
||||||
|
с телом в архиве.
|
||||||
|
|
||||||
|
Две версии с одинаковым ключом **внутри одной доставки** (позиции равны)
|
||||||
|
разрешаются минимумом канонической формы: порядок элементов в JSON-массиве
|
||||||
|
нестабилен, и опираться на него нельзя.
|
||||||
|
|
||||||
|
Почему **не** голый upsert по `id` (как делает сервер HealthyApps поверх
|
||||||
|
MongoDB и как просилось из формулировки «перезаписывается»): единственный
|
||||||
|
сценарий, ради которого тренировка приезжает повторно, — доезжающий маршрут,
|
||||||
|
то есть рост. Обратное — приезд версии без маршрута — за 44 доставленные копии
|
||||||
|
не случилось ни разу, но стоит 95% содержимого тренировки, а восстановление
|
||||||
|
требует пересборки всего журнала. Условие пункта 3 стоит одного сравнения
|
||||||
|
множеств и делает событие **наблюдаемым** вместо необратимого.
|
||||||
|
|
||||||
|
Несравнимые наборы (приехавшая принесла новые ключи и потеряла старые) в пункте
|
||||||
|
5 разрешаются в пользу сохранённой: поля не объединяются, объединение отвергнуто
|
||||||
|
там же, где для точек, — на живом потоке событие не наступало ни разу, и вместо
|
||||||
|
реализации заведено наблюдение.
|
||||||
|
|
||||||
|
**Остаточный предел назван вслух.** Слияние попарное — сохранённая против
|
||||||
|
приехавшей, — поэтому при несравнимых наборах (пункт 5) исход зависит от порядка
|
||||||
|
проигрывания. Тот же предел есть у часового объекта: в объекте лежит победитель
|
||||||
|
прошлых слияний, а не все кандидаты истории. Пункты 2–4 от порядка свёртки не
|
||||||
|
зависят, а пункт 5 сопровождается счётчиком и `WARN`, поэтому событие не будет
|
||||||
|
молчаливым.
|
||||||
|
|
||||||
|
### 4. Свёртка доставки остаётся одной транзакцией
|
||||||
|
|
||||||
|
`MergePoints` превращается в `Merge(ctx, Incoming{Points, Workouts, Records},
|
||||||
|
deliveryID)`: точки, тренировки и записи одной доставки пишутся одной
|
||||||
|
транзакцией. Спека хранения требует этого прямо («Доставка SHALL сворачиваться
|
||||||
|
одной транзакцией»), и требование не про точки, а про доставку: частичное
|
||||||
|
состояние ломает инвариант «состояние пересобираемо».
|
||||||
|
|
||||||
|
Наблюдение «ни одна доставка не несла двух секций сразу» (находка 50) собрано за
|
||||||
|
двое суток и основанием для второй транзакции не является.
|
||||||
|
|
||||||
|
Имя `MergePoints` уходит: метод перестал сливать одни точки, а два метода с
|
||||||
|
двумя транзакциями были бы вторым способом делать то же самое.
|
||||||
|
|
||||||
|
### 5. `payload` — сжатый блоб, как у часового объекта
|
||||||
|
|
||||||
|
Дословные байты сущности, gzip. Тот же приём и по той же причине, что у
|
||||||
|
`bucket`: маршрут — 95% веса тренировки, JSON такого рода жмётся примерно в
|
||||||
|
25 раз, а прогулка в час даёт порядка мегабайта. Цена названа там же и здесь та
|
||||||
|
же: внутрь `payload` не заглянуть SQL-функциями. Для хранилища, которое отдаёт
|
||||||
|
тренировку целиком, это не потеря; заголовок, по которому идёт выборка, лежит
|
||||||
|
колонками.
|
||||||
|
|
||||||
|
Отвергнуто хранение текстом (как было набросано в `architecture.md`, `payload
|
||||||
|
JSON`): второе кодирование для той же по природе величины стоило бы дороже
|
||||||
|
любой выгоды от `json_extract`, а объём — сотни мегабайт в год против десятков.
|
||||||
|
|
||||||
|
### 6. Заголовок тренировки — ровно то, по чему идёт выборка
|
||||||
|
|
||||||
|
`name`, `start_utc`, `end_utc`, `tz_offset`, `duration_sec`. Больше ничего:
|
||||||
|
любая следующая колонка — это решение за Apple о том, что в тренировке главное
|
||||||
|
(находка 15: сводки дублируют ряды, `distance` — это сумма
|
||||||
|
`walkingAndRunningDistance`).
|
||||||
|
|
||||||
|
`duration` берётся из тела, а не считается как `end - start`: HAE шлёт
|
||||||
|
91.746 секунды при интервале в 91 секунду, и вычисленное значение молча
|
||||||
|
разошлось бы с присланным. Отсутствует или не число — колонка **`NULL`**, а не
|
||||||
|
ноль: ноль — законная длительность, и потребитель, просуммировавший столбец, не
|
||||||
|
отличил бы «источник не прислал» от «измерено ноль». Тело в `payload` дословно в
|
||||||
|
любом случае.
|
||||||
|
|
||||||
|
`end` нечитаем или отсутствует — `end_utc` равен `start_utc`. У точки
|
||||||
|
вырождение интервала в мгновение запрещено, потому что схлопывает координату; у
|
||||||
|
сущности ключ — `id`, схлопывать нечего, а истина остаётся в `payload`. Офсет
|
||||||
|
берётся из `start`: колонка одна, а пробежка через смену зоны дала бы два
|
||||||
|
разных.
|
||||||
|
|
||||||
|
Метка записи — `start`, при его отсутствии `date`. `end` в заголовок не идёт:
|
||||||
|
у рода `daily_mood` он может отстоять от начала на сутки, и вторая колонка
|
||||||
|
понадобится вместе с запросом, которого пока нет.
|
||||||
|
|
||||||
|
`name` локализован («В помещении Ходьба»); хранится дословно, стабильный код
|
||||||
|
припишет задача словаря категориальных значений.
|
||||||
|
|
||||||
|
Значение `kind` у записи — верхнеуровневый ключ секции HAE **дословно**
|
||||||
|
(`stateOfMind`, не `state_of_mind`): инвариант «форма Apple не транслируется»
|
||||||
|
относится и к именам секций, а переименование после мерджа стоило бы миграции
|
||||||
|
данных.
|
||||||
|
|
||||||
|
### 7. Метка времени: у сущности оба известных формата, у точки — один
|
||||||
|
|
||||||
|
`parseEntityTime` пробует формат HAE (`2026-07-31 21:03:51 +0300`), затем
|
||||||
|
RFC 3339 (`2026-07-31T18:03:51Z`). Форматы измерены (находка 16: у тренировок
|
||||||
|
первый, у `stateOfMind` второй) и не пересекаются.
|
||||||
|
|
||||||
|
Отвергнуто приписывание формата секции: оно точнее описывает сегодняшний день и
|
||||||
|
ломается молча в тот, когда HAE выровняет секции между собой — а он к этому идёт
|
||||||
|
(`stateOfMind` уже шлёт честные коды HealthKit там, где старые секции шлют
|
||||||
|
переводы, находка 37). Цена терпимости нулевая: неоднозначности между двумя
|
||||||
|
формами нет.
|
||||||
|
|
||||||
|
**Точка остаётся строгой, и это не забывчивость.** У точки по метке выводится
|
||||||
|
слой, причём по метке **местной**: метка в UTC объявила бы часовую выгрузку
|
||||||
|
минутной, и минутный слой сложился бы с часовым (находка 35 — ровно такое
|
||||||
|
удвоение уже наблюдалось). Терпимый парсер там означал бы тихую порчу разреза;
|
||||||
|
строгий отдаёт непонятую метку в счётчик пропусков и `WARN`, а тело остаётся в
|
||||||
|
архиве. У сущности слоя нет, и терять на строгости нечего — асимметрия
|
||||||
|
намеренная.
|
||||||
|
|
||||||
|
Следствие, которое надо назвать вслух: у `stateOfMind` `tz_offset` всегда `0`,
|
||||||
|
потому что HAE прислал UTC, а не потому, что человек был в Гринвиче. Местная
|
||||||
|
зона этой секции в потоке отсутствует.
|
||||||
|
|
||||||
|
### 8. Отпечаток витрины покрывает сущности, и снимается одним снимком
|
||||||
|
|
||||||
|
`Store.Fingerprint` — единственный оракул сходимости `reindex` и `task
|
||||||
|
verify:archive`. Оставить его отпечатком одних часовых объектов значило бы
|
||||||
|
получить «состояние сошлось» при разъехавшихся тренировках — то есть сломать
|
||||||
|
проверку молча, ровно тем изменением, которое добавляет данные.
|
||||||
|
|
||||||
|
Три раздела читаются **одной read-only транзакцией**. Сегодня отпечаток — один
|
||||||
|
`SELECT`, то есть один снимок; три запроса подряд вне транзакции в режиме WAL
|
||||||
|
дают три снимка, а рабочий отпечаток снимается под живым приёмом. Свёртка,
|
||||||
|
закоммитившаяся между запросами, дала бы смесь «объекты до» и «тренировки
|
||||||
|
после», то есть ложное «разошлись» у единственного оракула. Прецедент в
|
||||||
|
проекте есть — `Store.Bucket` уже читает в `BeginTx(ReadOnly)`.
|
||||||
|
|
||||||
|
Строки разделов идут с константным тегом впереди (`b|`, `w|`, `r|`): без него
|
||||||
|
строка одного раздела может совпасть со строкой другого — та же причина, по
|
||||||
|
которой поля переменной длины уже идут с длиной впереди.
|
||||||
|
|
||||||
|
Отчёт `reindex` расширяется вместе с отпечатком: счётчики тренировок и записей
|
||||||
|
«было и стало» рядом с числом объектов, и «покрыта новая секция» в перечне
|
||||||
|
ожидаемых классов расхождения. Иначе первый же прогон после мерджа даст
|
||||||
|
гарантированное расхождение отпечатков при неизменившемся числе объектов — и
|
||||||
|
оракул выродится в шум ровно тогда, когда по нему принимается необратимое
|
||||||
|
решение о подмене базы.
|
||||||
|
|
||||||
|
### 9. Покрытыми становятся ровно две секции
|
||||||
|
|
||||||
|
`covered()` — множество из трёх имён: `metrics`, `workouts`, `stateOfMind`.
|
||||||
|
Прочие секции с собственным `id` остаются в списке непокрытых, доставка с ними
|
||||||
|
остаётся `partial`, тело — в архиве. Это честно: формы этих секций никто не
|
||||||
|
видел, а «полнота покрытия HealthKit ради полноты» целью проекта не является
|
||||||
|
(паспорт).
|
||||||
|
|
||||||
|
Следствие, которое надо назвать вслух и передать дальше: доставка из одного
|
||||||
|
`stateOfMind` теперь получает `parsed` с пустым списком непокрытых, то есть
|
||||||
|
становится **неотличимой** от доставки из метрик — а метрики восстановимы из
|
||||||
|
экспорта Apple, состояние разума нет (находка 46). До этой задачи защита
|
||||||
|
работала побочным эффектом непокрытости. Ретеншена в проекте нет, поэтому здесь
|
||||||
|
ничего не ломается сегодня; но предусловие, которое задача
|
||||||
|
`retenshen-syrogo-arhiva` считала снятым, снова открыто, и это записывается в
|
||||||
|
её файл тем же изменением.
|
||||||
|
|
||||||
|
### 9а. Отказ разбора остаётся «всё или ничего» — теперь и для сущностей
|
||||||
|
|
||||||
|
Действующее требование сформулировано через точки, потому что другого результата
|
||||||
|
у разбора не было. Три ветки надо назвать явно, иначе каждая решается
|
||||||
|
реализацией молча:
|
||||||
|
|
||||||
|
- **Тело оборвано после уже разобранной секции.** Разбор отдаёт ошибку и
|
||||||
|
**ни точек, ни сущностей**: иначе часть данных легла бы в витрину под
|
||||||
|
статусом, по которому доставку никто не подберёт.
|
||||||
|
- **Слой метрик не выводится, а в теле есть сущности.** Доставка целиком уходит
|
||||||
|
в `failed`, сущности не пишутся. Соблазн «сущностям слой не нужен, запишем
|
||||||
|
их» ломает то же «всё или ничего»: доставка получила бы `failed` при частично
|
||||||
|
записанной витрине, и повторная свёртка перестала бы быть no-op. Тело
|
||||||
|
остаётся в архиве, доставку вернёт пересборка. Цена названа: если такая
|
||||||
|
доставка когда-нибудь принесёт `stateOfMind`, его записи доедут не сразу, а
|
||||||
|
ретеншен `failed`-тела трогать не вправе.
|
||||||
|
- **Повтор ключа покрытой секции в одном `data`.** Секции **объединяются**, как
|
||||||
|
уже задано для `metrics`. Заодно чинится существующий дефект уровнем выше:
|
||||||
|
`decodeEnvelope` при повторе самого члена `data` результат второго члена
|
||||||
|
**присваивает**, а не добавляет, и имена первого глушатся общим `seen` — тело
|
||||||
|
с двумя `data` доезжает до `parsed` с молча потерянной секцией.
|
||||||
|
|
||||||
|
### 9б. Пределы на чужие строки
|
||||||
|
|
||||||
|
`id` приходит из тела и ничем не ограничен, а уезжает и в первичный ключ, и в
|
||||||
|
записи лога. Предел — 128 байт (UUID HealthKit — 36); сущность с более длинным
|
||||||
|
`id` пропускается тем же счётчиком, что и сущность без `id`. Правило то же, что
|
||||||
|
уже действует для имён непокрытых секций, и оно снимает класс, а не случай.
|
||||||
|
|
||||||
|
### 10. Миграция пересворачивает то, что стало покрытым
|
||||||
|
|
||||||
|
Спека хранения уже требует: «Задача, которая начинает разбирать секцию, тем же
|
||||||
|
изменением SHALL переводить `partial`-строки с этим ключом в `pending`».
|
||||||
|
Миграция `00007` переводит в `pending` доставки, у которых в
|
||||||
|
`uncovered_sections` встречается `workouts` или `stateOfMind`. Дальше их
|
||||||
|
подберёт обычный проход фонового воркера — отдельного кода для этого не
|
||||||
|
существует.
|
||||||
|
|
||||||
|
Перевод точечный, а не «все `partial`»: список — снимок покрытия, и доставка с
|
||||||
|
непокрытой `ecg` пересворачивать нечего.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **Приехала версия без маршрута, а поля при этом переименовались** → правило
|
||||||
|
пункта 5 удержит сохранённую версию навсегда, и новых полей витрина не
|
||||||
|
увидит. → Счётчик и `WARN` с `id` сущности; тело в архиве, `reindex` применит
|
||||||
|
исправленное правило. Событие не наблюдалось ни разу.
|
||||||
|
- **Сокращение внутри элемента ряда правилом не ловится.** Длина
|
||||||
|
верхнеуровневых массивов сравнивается, а точка маршрута, потерявшая
|
||||||
|
`altitude`, — нет. → Названо вслух; ловится только сверкой с телом в архиве.
|
||||||
|
- **Маршрут удлиняет транзакцию свёртки.** Верхняя граница задаётся не примером,
|
||||||
|
а пределом тела приёма: 64 МиБ распакованного тела из одних тренировок дают
|
||||||
|
десятки мегабайт содержимого в одной транзакции, а задача беклога «Цена
|
||||||
|
слияния на широкой доставке» уже описывает, как 63 МБ на одной координате
|
||||||
|
держат транзакцию дольше `busy_timeout`. → Хеш-детектор снимает 41 запись из
|
||||||
|
44 на нашем корпусе, а сравнение начинается с узкого `SELECT content_hash`,
|
||||||
|
без чтения и разжатия блоба. Отдельной задачи не заводим: случай выражается
|
||||||
|
той же беклоговой задачей, что и точки.
|
||||||
|
- **Канонизация маршрута разворачивает его в дерево `any`.** `canon.Form`
|
||||||
|
материализует значение целиком — та самая форма, от которой отказался разбор
|
||||||
|
тела (197 МиБ кучи против 54 МиБ на теле 42 МиБ). → Хеш приехавшей сущности
|
||||||
|
считается **один раз на доставку**, до входа в транзакцию, а не на каждой из
|
||||||
|
пяти попыток повтора при занятости базы.
|
||||||
|
- **`import` родного экспорта Apple не даст `id` тренировки.** В `export.xml`
|
||||||
|
элемент `Workout` идентификатора не несёт — `dogsheep/healthkit-to-sqlite`
|
||||||
|
поэтому адресует тренировку **хешем содержимого** (`hash_id="id"` в
|
||||||
|
sqlite-utils). Значит импорт снапшота задвоит тренировки, приехавшие от HAE, —
|
||||||
|
ровно та же дыра, что у точек, где её закрыли ключом `start + end`. → Предел
|
||||||
|
назван здесь и заводится задачей беклога; сегодня импорта нет, и решать это
|
||||||
|
до его формы значило бы угадывать.
|
||||||
|
- **`tz_offset` у записей состояния разума всегда ноль** — не потеря наша, а
|
||||||
|
форма источника. → Названо в `docs/database.md`, чтобы клиент не считал по
|
||||||
|
нему местные сутки.
|
||||||
|
- **Фикстуры собираются из живого архива.** Тренировка несёт координаты
|
||||||
|
маршрута, запись состояния разума — эмоциональные метки; и то и другое
|
||||||
|
чувствительнее токенов. → Скрипт `tmp/research/fixtures.py` расширяется:
|
||||||
|
вычищаются числа (включая широту и долготу), UUID, метки RFC 3339 и словарные
|
||||||
|
значения `stateOfMind`; сохраняются форма литерала, структура и порядок
|
||||||
|
ключей. Проверка «в индексе нет данных о здоровье» остаётся за гейтом.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Миграция `00007_workout_record.sql`:
|
||||||
|
|
||||||
|
1. `CREATE TABLE workout` и `CREATE TABLE record` с индексами по времени.
|
||||||
|
2. `UPDATE delivery SET parse_status = 'pending'` для строк, чей
|
||||||
|
`uncovered_sections` содержит `workouts` или `stateOfMind`.
|
||||||
|
|
||||||
|
Откат (`Down`) снимает таблицы; восстановление содержимого — обычная пересборка
|
||||||
|
из архива, витрина производна по построению. Строки, переведённые в `pending`,
|
||||||
|
`Down` обратно не возвращает: какими они были, восстановить неоткуда, а
|
||||||
|
`pending` консервативен — ретеншен его не трогает.
|
||||||
|
|
||||||
|
Живой сервис миграцию переживает: новые таблицы никого не блокируют, `UPDATE`
|
||||||
|
идёт по 118 строкам.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Половина живого потока разбором не покрыта. Замер по 118 доставкам архива:
|
||||||
|
65 несут `metrics`, 27 — `workouts`, 26 — `stateOfMind`. Тренировки и состояние
|
||||||
|
разума сохраняются в архив и числятся `partial`, но в витрину не попадают:
|
||||||
|
трекеру (второй потребитель паспорта) взять тренировку неоткуда, агенту-медику
|
||||||
|
состояние разума — тоже.
|
||||||
|
|
||||||
|
Для `stateOfMind` это дороже, чем для метрик: в родном экспорте Apple секции нет
|
||||||
|
ни одним типом (находка 46), то есть доставки HAE — её **единственный** источник,
|
||||||
|
и ретеншен архива без разобранной секции нельзя включать вовсе.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Разбор покрывает две новые секции тела: `workouts` и `stateOfMind`. Остальные
|
||||||
|
секции с собственными `id` (`ecg`, `symptoms`, `cycleTracking`, `medications`,
|
||||||
|
`heartRateNotifications`) остаются непокрытыми намеренно — живьём поток их не
|
||||||
|
приносил ни разу, и модель под них закладывается, а разбор — нет.
|
||||||
|
- Две новые таблицы: `workout` (заголовок колонками, всё остальное, включая
|
||||||
|
маршрут и внутренние ряды, — `payload` дословно) и `record` (секции с
|
||||||
|
собственным `id`, ключ `kind + id`).
|
||||||
|
- Сущность с собственным `id` **заменяется целиком**, а не сливается по полям.
|
||||||
|
Правило замены названо явно: приехавшая версия побеждает, если не теряет
|
||||||
|
содержания сохранённой; иначе сохранённая остаётся, факт считается и идёт в
|
||||||
|
`WARN`. При равных наборах полей выигрывает версия из более поздней доставки
|
||||||
|
**журнала**, а не свёрнутая последней, — иначе живая витрина расходилась бы с
|
||||||
|
пересборкой молча.
|
||||||
|
- Сущность несёт провенанс — доставку своей версии и её метку приёма.
|
||||||
|
- Разбор метки времени принимает второй формат — RFC 3339 в UTC, которым HAE шлёт
|
||||||
|
`stateOfMind` (находка 16).
|
||||||
|
- Отпечаток витрины (оракул сходимости `reindex`) покрывает тренировки и записи,
|
||||||
|
а не одни часовые объекты, и снимается одним снимком базы. Отчёт `reindex`
|
||||||
|
считает «до и после» по каждой единице хранения и называет «покрыта новая
|
||||||
|
секция» ожидаемым классом расхождения.
|
||||||
|
- «Отказ разбора — всё или ничего» распространяется на сущности явно, включая
|
||||||
|
ветку невыводимого слоя и повтор ключа секции.
|
||||||
|
- Миграция переводит в `pending` доставки, у которых в списке непокрытых секций
|
||||||
|
стоят ставшие покрытыми имена, — правило «покрыли секцию — пересверните» уже
|
||||||
|
записано в спеке хранения.
|
||||||
|
- **Не входит:** отдача тренировок и записей наружу. Read API в проекте пока нет
|
||||||
|
вовсе; его форма (конверт ответа, выбор слоя, предел размера) проектируется
|
||||||
|
задачей `read-api-tochki`, и вводить два эндпоинта раньше конверта значило бы
|
||||||
|
задать контракт мимоходом.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
Новых нет: тренировка и запись — это то же хранилище и тот же разбор, только
|
||||||
|
другая единица хранения. Отдельная capability создала бы второй словарь для того
|
||||||
|
же домена.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `parsing`: покрытых секций становится три вместо одной; появляется разбор
|
||||||
|
сущностей с собственным `id` и второй формат метки времени (RFC 3339),
|
||||||
|
оставленный предыдущей дельтой явно ненормированным; «всё или ничего»
|
||||||
|
распространяется на сущности.
|
||||||
|
- `storage`: появляется вторая единица хранения — сущность с собственным `id`, со
|
||||||
|
своим правилом замены версии; отпечаток витрины перестаёт быть отпечатком одних
|
||||||
|
часовых объектов; запрет на данные о здоровье в логах распространяется на
|
||||||
|
содержимое сущностей.
|
||||||
|
- `reindex`: счётчики отчёта покрывают все единицы хранения, а «покрыта новая
|
||||||
|
секция» становится названным классом ожидаемого расхождения.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `internal/hae` — разбор двух секций, второй формат метки, новые счётчики
|
||||||
|
пропусков.
|
||||||
|
- `internal/store` — таблицы `workout` и `record`, слияние доставки одной
|
||||||
|
транзакцией вместе с точками, отпечаток витрины.
|
||||||
|
- `internal/fold` — счётчики и единственный логирующий чекпоинт свёртки.
|
||||||
|
- `internal/replay` — ничего, кроме того, что отпечаток стал шире: пересборка
|
||||||
|
зовёт ту же свёртку.
|
||||||
|
- Миграция `00007` — две таблицы и перевод `partial`-доставок в `pending`.
|
||||||
|
- `docs/database.md`, `docs/architecture.md`, `docs/local-research.md` — схема,
|
||||||
|
правило замены версии и находка о поведении тренировки при переприсылке.
|
||||||
|
- Фикстуры `internal/hae/testdata` и скрипт их сборки `tmp/research/fixtures.py`:
|
||||||
|
тренировка с маршрутом и состояние разума — на реальных пакетах с вычищенными
|
||||||
|
измерениями.
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Разбор секций с собственными идентификаторами
|
||||||
|
|
||||||
|
Система SHALL разбирать секции тела, элементы которых несут собственный `id`, в
|
||||||
|
**сущности**, а не в точки: у сущности нет ни слоя, ни координатного ключа
|
||||||
|
`метрика + слой + начало + конец` — её адресует сам `id`.
|
||||||
|
|
||||||
|
Покрываются две такие секции: `data.workouts` и `data.stateOfMind`. Секции
|
||||||
|
`ecg`, `symptoms`, `cycleTracking`, `medications` и `heartRateNotifications`
|
||||||
|
покрытыми MUST NOT становиться: живой поток не приносил их ни разу (118
|
||||||
|
доставок), их форма никем не наблюдалась, а полнота покрытия HealthKit ради
|
||||||
|
полноты целью проекта не является. Они остаются в списке непокрытых, и доставка
|
||||||
|
с ними остаётся `partial`.
|
||||||
|
|
||||||
|
Из тренировки разбор SHALL брать только то, по чему потом идёт выборка:
|
||||||
|
идентификатор, имя, начало, конец, офсет исходной зоны и длительность. Всё
|
||||||
|
остальное — включая маршрут, внутренние ряды (`heartRateData`,
|
||||||
|
`activeEnergy`, `heartRateRecovery`) и сводки — MUST храниться содержимым
|
||||||
|
сущности **дословно**, теми же байтами, какими пришло. Раскладывать структуру
|
||||||
|
тренировки по колонкам значило бы решить за Apple, что в ней главное: сводки
|
||||||
|
дублируют ряды (`distance` — это сумма `walkingAndRunningDistance`), а набор
|
||||||
|
полей зависит от типа тренировки (у уличной есть `route`, `avgSpeed`,
|
||||||
|
`flightsClimbed`, у домашней — `temperature`, `humidity`, `intensity`).
|
||||||
|
|
||||||
|
Длительность SHALL браться из тела, а не вычисляться из начала и конца: HAE
|
||||||
|
шлёт `91.746` секунды при интервале в 91 секунду, и вычисленное значение молча
|
||||||
|
разошлось бы с присланным. Отсутствие или нечисловое значение длительности
|
||||||
|
сущность MUST NOT отбрасывать; такая длительность SHALL быть выражена
|
||||||
|
отсутствием значения, а не нулём — ноль является законной длительностью, и
|
||||||
|
потребитель не отличил бы «источник не прислал» от «измерено ноль».
|
||||||
|
|
||||||
|
Началом сущности SHALL быть `start`, при его отсутствии — `date`. Конец берётся
|
||||||
|
из `end`; при отсутствии или неразбираемости конца он SHALL равняться началу, а
|
||||||
|
истина остаётся в содержимом. Вырождение интервала здесь безопасно, в отличие от
|
||||||
|
точки: ключ сущности — `id`, схлопывать координаты нечем. Офсет исходной зоны
|
||||||
|
SHALL браться из начала: колонка одна, а тренировка через смену зоны дала бы
|
||||||
|
два разных.
|
||||||
|
|
||||||
|
Из записи разбор SHALL брать идентификатор, род секции, метку времени и офсет;
|
||||||
|
всё остальное хранится дословно. Род записи SHALL быть верхнеуровневым ключом
|
||||||
|
секции HAE **дословно** (`stateOfMind`, не `state_of_mind`): инвариант «форма
|
||||||
|
Apple не транслируется» относится и к именам секций.
|
||||||
|
|
||||||
|
Длина идентификатора SHALL быть ограничена, и сущность с более длинным `id`
|
||||||
|
SHALL пропускаться тем же счётчиком, что и сущность без `id`. Идентификатор
|
||||||
|
приходит из тела, которым отправитель управляет целиком, а уезжает и в ключ
|
||||||
|
таблицы, и в записи лога; правило то же, что уже действует для имён непокрытых
|
||||||
|
секций.
|
||||||
|
|
||||||
|
Ряд пульса **внутри** тренировки MUST NOT попадать в метрику `heart_rate`:
|
||||||
|
это разные сущности хранилища. Пульс приезжает дважды — в общем потоке метрик и
|
||||||
|
внутри тренировки, — и смешение задвоило бы ряд.
|
||||||
|
|
||||||
|
Сущность без `id` либо без разбираемой метки времени SHALL пропускаться со
|
||||||
|
счётчиком, не роняя разбор остального: тело остаётся в архиве, и доставку
|
||||||
|
подберёт пересборка, когда разбор научится её понимать.
|
||||||
|
|
||||||
|
Отсутствие покрытой секции в теле ошибкой быть MUST NOT: доставки из одних
|
||||||
|
метрик — большинство потока.
|
||||||
|
|
||||||
|
#### Scenario: Тренировка разбирается вместе с маршрутом
|
||||||
|
|
||||||
|
- **WHEN** тело содержит `data.workouts` с тренировкой, несущей `route`
|
||||||
|
- **THEN** разбор отдаёт сущность с идентификатором, именем, началом, концом,
|
||||||
|
офсетом и длительностью
|
||||||
|
- **AND** её содержимое несёт маршрут и внутренние ряды исходными байтами
|
||||||
|
|
||||||
|
#### Scenario: Ряд пульса тренировки не становится метрикой
|
||||||
|
|
||||||
|
- **WHEN** тренировка содержит `heartRateData`
|
||||||
|
- **THEN** точки этого ряда не попадают в точки метрик
|
||||||
|
- **AND** остаются внутри содержимого сущности
|
||||||
|
|
||||||
|
#### Scenario: Запись состояния разума разбирается
|
||||||
|
|
||||||
|
- **WHEN** тело содержит `data.stateOfMind` с элементом, несущим `id` и `start`
|
||||||
|
- **THEN** разбор отдаёт запись с родом `stateOfMind`, идентификатором, меткой
|
||||||
|
времени и содержимым исходными байтами
|
||||||
|
|
||||||
|
#### Scenario: Сущность без идентификатора пропускается
|
||||||
|
|
||||||
|
- **WHEN** элемент покрытой секции не несёт `id` либо `id` пуст
|
||||||
|
- **THEN** сущность в результат разбора не попадает
|
||||||
|
- **AND** факт учитывается счётчиком, а разбор остальных сущностей продолжается
|
||||||
|
|
||||||
|
#### Scenario: Элемент секции не является объектом
|
||||||
|
|
||||||
|
- **WHEN** элемент покрытой секции не разбирается как объект JSON
|
||||||
|
- **THEN** сущность в результат разбора не попадает
|
||||||
|
- **AND** факт учитывается **отдельным** счётчиком, а соседние сущности
|
||||||
|
разбираются как обычно
|
||||||
|
|
||||||
|
Отдельным, а не общим с «нет `id`»: доставка, где не разобрался сам элемент, —
|
||||||
|
это сменившаяся форма секции, а доставка без `id` — сменившаяся форма
|
||||||
|
идентификатора. Ронять из-за такого элемента всю доставку нельзя тем более:
|
||||||
|
`failed` фоновая свёртка не подбирает никогда, и вместе с одной кривой
|
||||||
|
тренировкой в него уехали бы записи `stateOfMind` той же доставки.
|
||||||
|
|
||||||
|
#### Scenario: Сущность без разбираемой метки времени пропускается
|
||||||
|
|
||||||
|
- **WHEN** элемент покрытой секции несёт `id`, но его метка времени не
|
||||||
|
разбирается ни одним из поддерживаемых форматов
|
||||||
|
- **THEN** сущность в результат разбора не попадает
|
||||||
|
- **AND** факт учитывается счётчиком
|
||||||
|
|
||||||
|
#### Scenario: Сущность со слишком длинным идентификатором пропускается
|
||||||
|
|
||||||
|
- **WHEN** элемент покрытой секции несёт `id` длиннее предела
|
||||||
|
- **THEN** сущность в результат разбора не попадает
|
||||||
|
- **AND** факт учитывается тем же счётчиком, что и отсутствие `id`
|
||||||
|
|
||||||
|
#### Scenario: Длительность берётся из тела, а не из интервала
|
||||||
|
|
||||||
|
- **WHEN** тренировка несёт `duration` равный `91.746` при интервале
|
||||||
|
`start`/`end` в 91 секунду
|
||||||
|
- **THEN** длительность сущности равна `91.746`
|
||||||
|
|
||||||
|
#### Scenario: Тренировка без длительности сохраняется без неё
|
||||||
|
|
||||||
|
- **WHEN** тренировка не несёт `duration` либо оно не является числом
|
||||||
|
- **THEN** сущность сохраняется, а её длительность остаётся незаполненной
|
||||||
|
- **AND** нулём она MUST NOT становиться
|
||||||
|
|
||||||
|
#### Scenario: Нечитаемый конец тренировки не отбрасывает её
|
||||||
|
|
||||||
|
- **WHEN** тренировка несёт `end`, который не разбирается
|
||||||
|
- **THEN** конец сущности равен её началу
|
||||||
|
- **AND** исходное значение остаётся в содержимом дословно
|
||||||
|
|
||||||
|
#### Scenario: Незнакомое поле тренировки переживает разбор
|
||||||
|
|
||||||
|
- **WHEN** тренировка несёт поле, которого разбор не знает
|
||||||
|
- **THEN** оно сохраняется в содержимом сущности дословно
|
||||||
|
- **AND** разбор не завершается ошибкой
|
||||||
|
|
||||||
|
#### Scenario: Непокрытая секция с собственными id остаётся непокрытой
|
||||||
|
|
||||||
|
- **WHEN** тело содержит `data.ecg`
|
||||||
|
- **THEN** `ecg` попадает в список непокрытых ключей
|
||||||
|
- **AND** сущностей из неё разбор не отдаёт
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Отказ разбора остаётся всё или ничего
|
||||||
|
|
||||||
|
Разбор SHALL оставаться операцией «всё или ничего»: ошибка, встреченная
|
||||||
|
**после** того, как покрытая секция уже разобрана (обрезанное тело, мусор в
|
||||||
|
следующем члене), MUST NOT оставлять в результате ни точек, ни сущностей —
|
||||||
|
доставка считается неразобранной целиком.
|
||||||
|
|
||||||
|
Иначе часть данных оказалась бы в витрине под статусом, по которому доставку
|
||||||
|
никто не подберёт, и свёртка перестала бы быть детерминированной по журналу.
|
||||||
|
|
||||||
|
Правило SHALL распространяться и на невыводимый слой: доставка, у которой есть
|
||||||
|
метрики, но слой их не определяется, не сохраняет и своих сущностей, хотя слоя
|
||||||
|
у сущности нет. Соблазн «сущности от слоя не зависят, запишем их» ломает то же
|
||||||
|
«всё или ничего» — доставка получила бы `failed` при частично записанной
|
||||||
|
витрине, и повторная свёртка перестала бы быть no-op. Цена названа вслух: если
|
||||||
|
такая доставка когда-нибудь принесёт `stateOfMind`, его записи доедут не сразу,
|
||||||
|
а пересборкой; тело при этом остаётся в архиве, и `failed` ретеншену трогать
|
||||||
|
нельзя.
|
||||||
|
|
||||||
|
Повтор ключа покрытой секции в одном объекте `data` SHALL давать объединение
|
||||||
|
секций, а не победу последней: молча терять данные нельзя. То же SHALL
|
||||||
|
относиться к повтору самого члена `data` в теле — результаты **накапливаются**,
|
||||||
|
включая список непокрытых ключей.
|
||||||
|
|
||||||
|
#### Scenario: Тело оборвано после секции метрик
|
||||||
|
|
||||||
|
- **WHEN** тело содержит целую секцию `metrics`, а следующий член `data`
|
||||||
|
оборван
|
||||||
|
- **THEN** разбор завершается ошибкой и точек не отдаёт
|
||||||
|
|
||||||
|
#### Scenario: Тело оборвано после секции тренировок
|
||||||
|
|
||||||
|
- **WHEN** тело содержит целую секцию `workouts`, а следующий член `data`
|
||||||
|
оборван
|
||||||
|
- **THEN** разбор завершается ошибкой и сущностей не отдаёт
|
||||||
|
|
||||||
|
#### Scenario: Невыводимый слой не сохраняет и сущностей
|
||||||
|
|
||||||
|
- **WHEN** доставка несёт метрики, слой которых не определяется, и вместе с
|
||||||
|
ними секцию `stateOfMind`
|
||||||
|
- **THEN** разбор завершается ошибкой, ни точек, ни записей не отдаёт
|
||||||
|
- **AND** список непокрытых ключей переживает отказ
|
||||||
|
|
||||||
|
#### Scenario: Секция метрик встречается дважды
|
||||||
|
|
||||||
|
- **WHEN** объект `data` содержит два ключа `metrics`
|
||||||
|
- **THEN** точки обеих секций попадают в результат
|
||||||
|
|
||||||
|
#### Scenario: Секция тренировок встречается дважды
|
||||||
|
|
||||||
|
- **WHEN** объект `data` содержит два ключа `workouts`
|
||||||
|
- **THEN** сущности обеих секций попадают в результат
|
||||||
|
|
||||||
|
#### Scenario: Член `data` встречается дважды
|
||||||
|
|
||||||
|
- **WHEN** тело содержит два члена `data`, из которых первый несёт непокрытую
|
||||||
|
секцию, а второй — покрытую
|
||||||
|
- **THEN** данные покрытой секции попадают в результат
|
||||||
|
- **AND** имя непокрытой секции остаётся в списке непокрытых ключей
|
||||||
|
|
||||||
|
### Requirement: Разбор форматов времени
|
||||||
|
|
||||||
|
Система SHALL разбирать метку формата `2026-07-31 21:03:51 +0300` и приводить
|
||||||
|
её к UTC, сохраняя офсет исходной зоны. В секции `data.metrics` других форматов
|
||||||
|
меток не встречается.
|
||||||
|
|
||||||
|
Система SHALL разбирать вторым форматом RFC 3339 в UTC
|
||||||
|
(`2026-07-31T18:03:51Z`): им приходят метки секции `data.stateOfMind`, тогда
|
||||||
|
как тренировки и метрики шлют первый формат. Оба формата SHALL приниматься **у
|
||||||
|
любой** метки сущности, а не приписываться секции жёстко: формы однозначны и не
|
||||||
|
пересекаются, а HAE выравнивает секции между собой по ходу своих обновлений —
|
||||||
|
`stateOfMind` уже шлёт стабильные коды HealthKit там, где старые секции шлют
|
||||||
|
переводы. Приписанный секции формат ломался бы молча в день такого выравнивания.
|
||||||
|
|
||||||
|
Метка RFC 3339 в UTC даёт офсет `0`, и это MUST означать «источник прислал
|
||||||
|
UTC», а не «человек находился в нулевой зоне»: местной зоны у секции
|
||||||
|
`stateOfMind` в потоке нет вовсе.
|
||||||
|
|
||||||
|
Метка **точки** при этом остаётся строгой — один формат, — и асимметрия
|
||||||
|
намеренная. По метке точки выводится слой, причём по метке в **исходной зоне**;
|
||||||
|
терпимость к RFC 3339 означала бы, что метка в UTC тихо портит выравнивание и
|
||||||
|
часовая выгрузка складывается с минутной (наблюдалось: удвоение суммы за час).
|
||||||
|
У сущности слоя нет, и терять на строгости нечего, а у точки строгий парсер
|
||||||
|
отдаёт непонятую метку в счётчик пропусков — тело остаётся в архиве, и
|
||||||
|
пересборка вернёт его, когда формат станет известен.
|
||||||
|
|
||||||
|
Unix-эпоха дробным числом (`1785446196.4132624`) встречается **внутри**
|
||||||
|
`heartbeatSeries` и меткой точки не является. Система MUST NOT преобразовывать
|
||||||
|
её: элементы серии проходят как исходные байты. Преобразование во `time.Unix`
|
||||||
|
и обратно не гарантирует дословности, а серия составляет 93% объёма метрики
|
||||||
|
`heart_rate_variability`.
|
||||||
|
|
||||||
|
Время внутри маршрута тренировки (`route[].timestamp`) меткой сущности тоже не
|
||||||
|
является и MUST проходить дословно, не разбираясь.
|
||||||
|
|
||||||
|
#### Scenario: Локальное время со смещением
|
||||||
|
|
||||||
|
- **WHEN** метка имеет вид `2026-07-31 21:03:51 +0300`
|
||||||
|
- **THEN** точка получает время в UTC и офсет `+10800` секунд
|
||||||
|
|
||||||
|
#### Scenario: RFC 3339 в UTC
|
||||||
|
|
||||||
|
- **WHEN** метка сущности имеет вид `2026-07-31T18:03:51Z`
|
||||||
|
- **THEN** сущность получает время в UTC и офсет `0`
|
||||||
|
|
||||||
|
#### Scenario: Тренировка со временем в формате метрик
|
||||||
|
|
||||||
|
- **WHEN** тренировка несёт `start` вида `2026-08-01 10:04:31 +0300`
|
||||||
|
- **THEN** сущность получает время в UTC и офсет `+10800` секунд
|
||||||
|
|
||||||
|
#### Scenario: Время внутри серии ударов
|
||||||
|
|
||||||
|
- **WHEN** точка метрики `heart_rate_variability` содержит `heartbeatSeries`
|
||||||
|
- **THEN** элементы серии сохраняются исходными байтами вместе с их эпохой
|
||||||
|
- **AND** серия не разворачивается в отдельные точки
|
||||||
|
- **AND** эпоха внутри серии не разбирается и не преобразуется
|
||||||
|
|
||||||
|
#### Scenario: Время внутри маршрута не разбирается
|
||||||
|
|
||||||
|
- **WHEN** тренировка содержит `route` с полем `timestamp` у каждой точки
|
||||||
|
- **THEN** точки маршрута сохраняются исходными байтами
|
||||||
|
- **AND** их метки не разбираются и не преобразуются
|
||||||
|
|
||||||
|
### Requirement: Перечисление непокрытых секций доставки
|
||||||
|
|
||||||
|
Разбор SHALL перечислять верхнеуровневые ключи объекта `data` и возвращать
|
||||||
|
вызывающему те из них, которые он не покрывает. Содержимое непокрытой секции
|
||||||
|
MUST NOT удерживаться после того, как разбор прошёл мимо неё: тела доходят до
|
||||||
|
42 МиБ, и удержание кучи здесь — часть контракта, а не деталь реализации.
|
||||||
|
|
||||||
|
Покрытых ключей сегодня три — `metrics`, `workouts` и `stateOfMind`. Разбор и
|
||||||
|
перечисление MUST ходить по одному объявленному множеству покрытых имён:
|
||||||
|
состояние «секция разбирается, но числится непокрытой» невыразимо по построению.
|
||||||
|
|
||||||
|
Непокрытым ключ считается независимо от того, что лежит внутри: содержимое не
|
||||||
|
интерпретируется, поэтому и о пустоте секции разбор честно ничего не знает.
|
||||||
|
Измерено на живом архиве — пустых секций HAE не присылает ни разу (118 доставок).
|
||||||
|
|
||||||
|
Список SHALL быть каноничен: имена отсортированы, повторов нет. Порядок ключей в
|
||||||
|
JSON от HAE нестабилен, а значение уезжает в базу и сравнивается между
|
||||||
|
доставками.
|
||||||
|
|
||||||
|
Отсутствие непокрытых ключей и отсутствие секции `metrics` — разные события, и
|
||||||
|
оба нормальны: половина потока состоит из доставок без метрик вовсе (53 из 118).
|
||||||
|
|
||||||
|
#### Scenario: Незнакомая секция попадает в список непокрытых
|
||||||
|
|
||||||
|
- **WHEN** тело содержит `data.ecg` наряду с `data.metrics`
|
||||||
|
- **THEN** разбор возвращает `ecg` в списке непокрытых ключей
|
||||||
|
- **AND** точки секции `metrics` разбираются как обычно
|
||||||
|
|
||||||
|
#### Scenario: Доставка из одних тренировок непокрытых ключей не даёт
|
||||||
|
|
||||||
|
- **WHEN** тело содержит только `data.workouts`
|
||||||
|
- **THEN** разбор завершается без ошибки, точек нет, тренировки разобраны
|
||||||
|
- **AND** список непокрытых ключей пуст
|
||||||
|
|
||||||
|
#### Scenario: Доставка из одного состояния разума непокрытых ключей не даёт
|
||||||
|
|
||||||
|
- **WHEN** тело содержит только `data.stateOfMind`
|
||||||
|
- **THEN** разбор завершается без ошибки, записи разобраны
|
||||||
|
- **AND** список непокрытых ключей пуст
|
||||||
|
|
||||||
|
#### Scenario: Доставка из одних метрик непокрытых ключей не даёт
|
||||||
|
|
||||||
|
- **WHEN** единственный ключ `data` — `metrics`
|
||||||
|
- **THEN** список непокрытых ключей пуст
|
||||||
|
|
||||||
|
#### Scenario: Один и тот же набор секций даёт один и тот же список
|
||||||
|
|
||||||
|
- **WHEN** два тела несут те же секции в разном порядке, а одно из них
|
||||||
|
повторяет непокрытый ключ дважды
|
||||||
|
- **THEN** списки непокрытых ключей у них совпадают
|
||||||
|
|
||||||
|
#### Scenario: Содержимое непокрытой секции не удерживается в памяти
|
||||||
|
|
||||||
|
- **WHEN** тело в десятки мегабайт состоит преимущественно из непокрытой секции
|
||||||
|
- **THEN** после разбора удержано не больше четырёх размеров тела — та же
|
||||||
|
граница, что и для тела из метрик
|
||||||
|
- **AND** содержимое непокрытой секции в результат разбора не попадает
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Отчёт, оракул и исход команды
|
||||||
|
|
||||||
|
Система SHALL завершать пересборку отчётом, который несёт счётчики
|
||||||
|
(проиграно, свёрнуто, отказов по классам, тел без учётной записи, строк без
|
||||||
|
тела, пропущенных файлов, повторов, объектов **до и после**) и **два
|
||||||
|
отпечатка** — рабочей витрины и пересобранной, — с прямым ответом, совпали они
|
||||||
|
или нет.
|
||||||
|
|
||||||
|
Счётчики «до и после» SHALL покрывать **каждую единицу хранения витрины**:
|
||||||
|
часовые объекты, тренировки и записи. Отпечаток отвечает «да/нет» за витрину
|
||||||
|
целиком, поэтому единица, которой нет в счётчиках, делает расхождение
|
||||||
|
безадресным: человек увидит «не совпало» при неизменившемся числе объектов и не
|
||||||
|
отличит появление двадцати семи тренировок от пропажи двух.
|
||||||
|
|
||||||
|
Отказы SHALL считаться **по классам**: слой не выводится, содержимое не
|
||||||
|
разбирается, работа отложена по обстоятельствам, всё прочее. Невыведенный слой
|
||||||
|
есть в каждом журнале и штатен; общий счётчик отправлял бы человека искать
|
||||||
|
дефект там, где его нет. Отдельно называть человеку следует только нештатные
|
||||||
|
отказы.
|
||||||
|
|
||||||
|
Отложенная доставка (занятость базы, отмена работы снаружи) SHALL считаться
|
||||||
|
нештатной **для пересборки**, хотя для фоновой свёртки она штатна: пересборка
|
||||||
|
идёт в свежий файл при единственном писателе, и такая доставка в собранной
|
||||||
|
витрине просто отсутствует — вместе с теми, кто наследовал от неё слой. Классы
|
||||||
|
при этом общие с фоновой свёрткой: второй классификатор разошёлся бы с первым
|
||||||
|
молча.
|
||||||
|
|
||||||
|
Число объектов «было и стало» SHALL печататься рядом с отпечатками: отпечатки
|
||||||
|
отвечают «да/нет», а решение о подмене необратимо, и по «да/нет» нельзя
|
||||||
|
судить о **направлении** расхождения. Именно пара чисел — 1737 против 1742 —
|
||||||
|
поймала прошлый дефект наследования слоя.
|
||||||
|
|
||||||
|
Отпечаток здесь оракул, а не украшение: число объектов к правилу разрешения
|
||||||
|
столкновений нечувствительно — на координате всегда ровно одна точка, и правило
|
||||||
|
выбирает, какая, а не сколько. «Объектов столько же» совпало бы и при заведомо
|
||||||
|
сломанном правиле.
|
||||||
|
|
||||||
|
Отпечаток рабочей витрины SHALL сниматься **до** начала проигрывания, а число
|
||||||
|
доставок в рабочей базе — до и после. Ненулевая разница SHALL называться в
|
||||||
|
отчёте, и при ней процедура подмены печататься MUST NOT: доставки, приехавшие за
|
||||||
|
время прогона, есть в рабочей базе и в архиве, но не в собранном файле, и
|
||||||
|
подмена стёрла бы их учёт вместе с заголовками, которых в архиве нет.
|
||||||
|
|
||||||
|
Величины, которые не снимались, отчёт печатать MUST NOT. При отмене отпечаток
|
||||||
|
пересобранной витрины и число доставок после прогона не измеряются вовсе —
|
||||||
|
печатать их сравнение значило бы выдать неизмеренное за измеренное, причём в
|
||||||
|
единственном оракуле задачи. Ожидаемые классы расхождения (новые доставки за время прогона,
|
||||||
|
непереносимый признак запечатанного часа, исправленный разбор, **покрытая
|
||||||
|
разбором новая секция**) SHALL называться отдельно от самого факта расхождения.
|
||||||
|
|
||||||
|
Класс «покрыта новая секция» назван потому, что первый прогон после такого
|
||||||
|
изменения расходится **гарантированно** и штатно: витрина обзаводится единицами
|
||||||
|
хранения, которых в рабочей базе нет по построению. Не назвав его, отчёт
|
||||||
|
приучает человека игнорировать расхождение отпечатков — то есть обесценивает
|
||||||
|
оракул ровно тогда, когда по нему принимается необратимое решение.
|
||||||
|
|
||||||
|
**Исход команды.** Расхождение отпечатков отказом быть MUST NOT: после
|
||||||
|
исправления разбора оно ожидаемо и есть сам смысл пересборки. Отказ отдельной
|
||||||
|
доставки отказом команды тоже MUST NOT быть: доставка, слой которой не
|
||||||
|
выводится, — штатный исход.
|
||||||
|
|
||||||
|
Отказом команды SHALL быть: пустой журнал, отсутствие хотя бы одной свёрнутой
|
||||||
|
доставки, отмена и любая ошибка окружения. Пустая витрина совпадает по
|
||||||
|
отпечатку с пустой витриной, поэтому прогон по пустому журналу выглядит
|
||||||
|
идеальной сходимостью — а все умолчания подыгрывают такому запуску: конфига
|
||||||
|
может не быть вовсе, и тогда пути указывают в рабочий каталог процесса. Человек,
|
||||||
|
выполнивший напечатанную процедуру, заменил бы витрину пустой.
|
||||||
|
|
||||||
|
Отчёт значений точек, имён метрик, имён устройств и содержимого тел содержать
|
||||||
|
MUST NOT: отпечаток берёт содержимое хешем. Ограничение относится к отчёту в
|
||||||
|
стандартном выводе; лог свёртки живёт по правилам спеки хранения, где координаты
|
||||||
|
столкновения (метрика, слой, час) разрешены явно.
|
||||||
|
|
||||||
|
Отчёт идёт в стандартный вывод человеческим текстом. Прогресс длинного прогона
|
||||||
|
SHALL идти в поток ошибок, а не смешиваться с отчётом: прогон на полном архиве
|
||||||
|
молчит минутами, и зависший неотличим от идущего.
|
||||||
|
|
||||||
|
#### Scenario: Отчёт сравнивает отпечатки
|
||||||
|
|
||||||
|
- **WHEN** пересборка завершилась
|
||||||
|
- **THEN** отчёт содержит отпечаток рабочей витрины и отпечаток пересобранной
|
||||||
|
- **AND** прямо называет, совпали они или нет
|
||||||
|
- **AND** называет, изменилось ли число доставок в рабочей базе за время прогона
|
||||||
|
|
||||||
|
#### Scenario: Счётчики покрывают все единицы хранения
|
||||||
|
|
||||||
|
- **WHEN** пересборка завершилась
|
||||||
|
- **THEN** отчёт печатает «до и после» отдельно для часовых объектов,
|
||||||
|
тренировок и записей
|
||||||
|
|
||||||
|
#### Scenario: Расхождение отпечатков не является отказом
|
||||||
|
|
||||||
|
- **WHEN** отпечаток пересобранной витрины отличается от рабочей, и при этом
|
||||||
|
хотя бы одна доставка свёрнута
|
||||||
|
- **THEN** команда завершается успешно, а расхождение названо в отчёте
|
||||||
|
|
||||||
|
#### Scenario: Пустой журнал — отказ, а не идеальная сходимость
|
||||||
|
|
||||||
|
- **WHEN** в архиве не нашлось ни одного тела
|
||||||
|
- **THEN** команда завершается ненулевым кодом
|
||||||
|
- **AND** процедуры подмены не печатает
|
||||||
|
|
||||||
|
#### Scenario: Ни одна доставка не свернулась
|
||||||
|
|
||||||
|
- **WHEN** журнал непуст, но свернуть не удалось ни одной доставки
|
||||||
|
- **THEN** команда завершается ненулевым кодом
|
||||||
|
- **AND** процедуры подмены не печатает
|
||||||
|
|
||||||
|
#### Scenario: Приезд доставок за время прогона отменяет подмену
|
||||||
|
|
||||||
|
- **WHEN** число доставок в рабочей базе за время прогона изменилось
|
||||||
|
- **THEN** отчёт называет разницу
|
||||||
|
- **AND** процедуры подмены не печатает
|
||||||
|
|
||||||
|
#### Scenario: Отчёт после отмены не сравнивает неизмеренного
|
||||||
|
|
||||||
|
- **WHEN** прогон отменён
|
||||||
|
- **THEN** отчёт не содержит ни ответа о совпадении отпечатков, ни разницы
|
||||||
|
числа доставок
|
||||||
|
|
||||||
|
#### Scenario: Рабочей базы нет вовсе
|
||||||
|
|
||||||
|
- **WHEN** файла рабочей базы не существует
|
||||||
|
- **THEN** пересборка идёт по одним подобранным телам
|
||||||
|
- **AND** отчёт называет, что сверять не с чем и что заголовки доставок не
|
||||||
|
восстанавливаются
|
||||||
|
|
||||||
|
#### Scenario: Отчёт не раскрывает данных о здоровье
|
||||||
|
|
||||||
|
- **WHEN** отчёт напечатан
|
||||||
|
- **THEN** он не содержит ни значений точек, ни имён метрик, ни имён устройств
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Хранение сущностей с собственным идентификатором
|
||||||
|
|
||||||
|
Система SHALL хранить тренировки и записи секций с собственным `id` **не**
|
||||||
|
часовыми объектами, а по одной строке на сущность: у них есть естественный
|
||||||
|
ключ, они редки (за двое суток потока — две тренировки и две записи состояния
|
||||||
|
разума), и группировать их по часам незачем.
|
||||||
|
|
||||||
|
Единиц хранения две:
|
||||||
|
|
||||||
|
```
|
||||||
|
тренировка ключ id
|
||||||
|
заголовок колонками: имя, начало, конец, офсет зоны, длительность
|
||||||
|
запись ключ род секции + id
|
||||||
|
заголовок колонками: род, метка времени, офсет зоны
|
||||||
|
```
|
||||||
|
|
||||||
|
Сущность SHALL нести **провенанс** — идентификатор доставки, чья версия лежит
|
||||||
|
сейчас, и метку приёма этой доставки. Он нужен не отчётности: по нему
|
||||||
|
разрешается тай-брейк между версиями равной полноты (см. «Замена версии
|
||||||
|
сущности…»), и без него `WARN` об удержанной обеднённой версии не связать с
|
||||||
|
телом в архиве.
|
||||||
|
|
||||||
|
Длительность тренировки SHALL допускать отсутствие значения, отличимое от нуля:
|
||||||
|
ноль — законная длительность, и потребитель, сложивший столбец, иначе не отличил
|
||||||
|
бы «источник не прислал» от «измерено ноль».
|
||||||
|
|
||||||
|
Ключ записи SHALL быть парой `род + id`, а не одним `id`. Собственный `id`
|
||||||
|
наблюдался живьём только у `stateOfMind`, где он UUID HealthKit; форма
|
||||||
|
идентификатора остальных пяти секций не наблюдалась никем, и короткий
|
||||||
|
несквозной `id` в двух разных секциях затёр бы одну запись другой молча. Пара
|
||||||
|
стоит ноль: запросы к записям всегда идут с родом.
|
||||||
|
|
||||||
|
Содержимое сущности SHALL храниться **дословно** — теми же байтами, какими
|
||||||
|
пришло, включая маршрут, внутренние ряды и сводки. Заголовок колонками
|
||||||
|
существует ради выборки по времени и не является разбором содержимого: любая
|
||||||
|
следующая колонка была бы решением за Apple о том, что в тренировке главное.
|
||||||
|
|
||||||
|
Ряд пульса внутри тренировки MUST лежать в её содержимом, а не в объектах
|
||||||
|
метрики `heart_rate`: это разные таблицы, и смешение задвоило бы ряд.
|
||||||
|
|
||||||
|
Сущности доставки SHALL записываться **той же транзакцией**, что и её точки.
|
||||||
|
Доставка — единица свёртки; частичное состояние ломает инвариант «состояние
|
||||||
|
пересобираемо», а наблюдение «секции не смешиваются в одной доставке» собрано
|
||||||
|
за двое суток и основанием для второй транзакции не является.
|
||||||
|
|
||||||
|
Система SHALL хранить рядом с сущностью хеш её канонического содержимого и
|
||||||
|
пропускать запись, если хеш не изменился. Тренировка переприсылается каждой
|
||||||
|
доставкой автоматизации, пока не доедет маршрут: на живом архиве 44 доставленные
|
||||||
|
копии дают три различных содержимых.
|
||||||
|
|
||||||
|
Сравнение SHALL начинаться с хеша, читаемого **без** содержимого сохранённой
|
||||||
|
сущности: маршрут доходит до мегабайта, разжимать и канонизировать его на каждой
|
||||||
|
из 44 копий не за чем. Хеш приехавшей сущности SHALL считаться один раз на
|
||||||
|
доставку, а не на каждой попытке повтора транзакции при занятости базы:
|
||||||
|
канонизация материализует значение целиком, и повтор умножал бы пик кучи.
|
||||||
|
|
||||||
|
#### Scenario: Тренировка хранится одной строкой с маршрутом
|
||||||
|
|
||||||
|
- **WHEN** приезжает тренировка с маршрутом
|
||||||
|
- **THEN** она хранится одной строкой, адресуемой своим `id`
|
||||||
|
- **AND** маршрут и внутренние ряды лежат в её содержимом дословно
|
||||||
|
|
||||||
|
#### Scenario: Ряд пульса тренировки не попадает в метрику
|
||||||
|
|
||||||
|
- **WHEN** тренировка несёт `heartRateData`
|
||||||
|
- **THEN** объектов метрики `heart_rate` эта доставка не создаёт
|
||||||
|
|
||||||
|
#### Scenario: Записи разных родов с одинаковым id не сталкиваются
|
||||||
|
|
||||||
|
- **WHEN** две записи разных родов приезжают с одним и тем же `id`
|
||||||
|
- **THEN** в хранилище лежат обе
|
||||||
|
|
||||||
|
#### Scenario: Повторная присылка той же тренировки не пишет в базу
|
||||||
|
|
||||||
|
- **WHEN** приезжает тренировка, содержимое которой совпадает с сохранённым
|
||||||
|
- **THEN** хеш совпадает и запись не выполняется
|
||||||
|
|
||||||
|
#### Scenario: Отказ посреди доставки не оставляет части сущностей
|
||||||
|
|
||||||
|
- **WHEN** свёртка доставки прерывается на середине
|
||||||
|
- **THEN** не записывается ни одна сущность этой доставки
|
||||||
|
|
||||||
|
### Requirement: Замена версии сущности не теряет содержания
|
||||||
|
|
||||||
|
Сущность с собственным `id` SHALL замещаться **целиком**, а не сливаться по
|
||||||
|
полям: она приезжает повторно, пока источник её досчитывает. Замер на живом
|
||||||
|
архиве: одна тренировка приехала 26 раз в трёх различных содержимых — сперва
|
||||||
|
добавились `stepCadence` и `stepCount` вместе с изменившимся рядом
|
||||||
|
`activeEnergy`, затем при том же наборе полей досчитались `totalEnergy` и
|
||||||
|
`basalEnergy`.
|
||||||
|
|
||||||
|
Замещение MUST быть условным: приехавшая версия побеждает, **если не теряет
|
||||||
|
содержания** сохранённой. Порядок разбора:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. хеш канонического содержимого совпал → записи нет
|
||||||
|
2. содержание приехавшей покрывает сохранённую
|
||||||
|
и сверх того → приехавшая замещает целиком
|
||||||
|
3. приехавшая теряет содержание сохранённой → остаётся сохранённая,
|
||||||
|
счётчик + WARN
|
||||||
|
4. содержание сравнимо, наборы равны → версия из более поздней
|
||||||
|
доставки журнала
|
||||||
|
5. наборы несравнимы → остаётся сохранённая,
|
||||||
|
счётчик + WARN
|
||||||
|
```
|
||||||
|
|
||||||
|
**Содержание сравнивается множеством ключей с непустым значением — и только им.**
|
||||||
|
Сравнение полноты, принятое для точек, здесь неприменимо: оно гасит отношение
|
||||||
|
включения, когда значения общих содержательных ключей разошлись, а у сущности
|
||||||
|
они расходятся **всегда** — источник её досчитывает. Проверено: сохранённая
|
||||||
|
тренировка с маршрутом против приехавшей без маршрута даёт «надмножество» при
|
||||||
|
неизменных значениях и «равенство» при изменившихся, то есть на живых данных
|
||||||
|
защита не сработала бы вовсе, а тест на фикстуре с неизменёнными значениями
|
||||||
|
остался бы зелёным. Условия «значения общих ключей совпали» здесь быть MUST NOT.
|
||||||
|
|
||||||
|
Дополнительно к множеству ключей SHALL сравниваться **длина верхнеуровневых
|
||||||
|
массивов**: усечённый маршрут (три точки вместо 593) ключа не теряет, а теряет
|
||||||
|
95% содержимого тренировки. Досчёт ряды удлиняет, поэтому укорачивание —
|
||||||
|
законный признак «приехало меньше». Предел правила называется вслух: сокращение
|
||||||
|
**внутри** элемента ряда (точка маршрута без `altitude`) не ловится ничем, кроме
|
||||||
|
сверки с телом в архиве.
|
||||||
|
|
||||||
|
Единственная причина повторной присылки — доезжающий маршрут, то есть рост:
|
||||||
|
обратного за 44 доставленные копии не случилось ни разу. Но восстановление
|
||||||
|
требует пересборки всего журнала, поэтому событие делается наблюдаемым, а не
|
||||||
|
необратимым.
|
||||||
|
|
||||||
|
**Тай-брейк при равных наборах — позиция доставки в журнале `(received_at, id)`,
|
||||||
|
а не порядок свёртки.** «Побеждает приехавшая» было бы функцией порядка
|
||||||
|
свёртки, а он порядку журнала не равен: воркер сворачивает в порядке журнала
|
||||||
|
только среди видимых ему доставок и абсолютного порядка при конкурентных
|
||||||
|
приёмах не обещает. Доставка с более ранней меткой, свёрнутая позже, вернула бы
|
||||||
|
витрину к недосчитанной версии, и пересборка разошлась бы с живым приёмом молча,
|
||||||
|
в содержимом тренировки. Позиция журнала снимает это: исход зависит от журнала,
|
||||||
|
а не от того, кто раньше добрался до базы.
|
||||||
|
|
||||||
|
Отличие от точки здесь содержательное: у точки на одних координатах законно
|
||||||
|
встречаются два разных измерения, и предпочитать позднее нет оснований — там
|
||||||
|
исход решает порядок канонических форм. У сущности `id` — идентичность одного
|
||||||
|
объекта HealthKit, и вторая версия есть тот же объект, пересчитанный источником;
|
||||||
|
тай-брейк по канонической форме заморозил бы тренировку на произвольной из
|
||||||
|
версий навсегда, вместе с недосчитанной энергией.
|
||||||
|
|
||||||
|
Две версии одного ключа **внутри одной доставки** позициями не различаются и
|
||||||
|
SHALL разрешаться минимумом канонической формы — включая случай несравнимых
|
||||||
|
наборов. Внутри доставки «сохранённой» версии не существует, есть только
|
||||||
|
порядок элементов в JSON-массиве, а он нестабилен: правило «остаётся первая
|
||||||
|
встреченная» сделало бы исход функцией порядка на проводе. Сворачиваться между
|
||||||
|
собой такие версии SHALL до сравнения с сохранённой, а факт «в одном теле
|
||||||
|
приехали две версии одного ключа с разным содержанием» SHALL считаться
|
||||||
|
**симметрично**: счётчик, зависящий от порядка элементов, наблюдал бы событие
|
||||||
|
через раз.
|
||||||
|
|
||||||
|
Поля версий MUST NOT объединяться: несравнимые наборы (приехавшая принесла
|
||||||
|
новые ключи и потеряла старые) разрешаются в пользу сохранённой и считаются
|
||||||
|
тем же счётчиком. Объединение отвергнуто там же и по той же причине, что для
|
||||||
|
точек: на живом потоке событие не наступало, и вместо реализации заведено
|
||||||
|
наблюдение.
|
||||||
|
|
||||||
|
Исход SHALL быть функцией журнала в его порядке. Остаточный предел называется
|
||||||
|
вслух: слияние попарное — сохранённая против приехавшей, — поэтому при
|
||||||
|
несравнимых наборах (пункт 5) исход зависит от порядка проигрывания. Тот же
|
||||||
|
предел есть у часового объекта, где хранится победитель прошлых слияний, а не
|
||||||
|
все кандидаты истории; пункты 2–4 от порядка свёртки не зависят, а пункт 5
|
||||||
|
сопровождается счётчиком и `WARN`.
|
||||||
|
|
||||||
|
#### Scenario: Доехавший маршрут замещает тренировку без маршрута
|
||||||
|
|
||||||
|
- **WHEN** та же тренировка приезжает повторно, добавив `route`
|
||||||
|
- **THEN** в хранилище лежит версия с маршрутом
|
||||||
|
|
||||||
|
#### Scenario: Досчитанные значения при том же наборе полей побеждают
|
||||||
|
|
||||||
|
- **WHEN** та же тренировка приезжает повторно с тем же набором полей и
|
||||||
|
изменившимися значениями, доставкой с более поздней позицией журнала
|
||||||
|
- **THEN** в хранилище лежит приехавшая версия
|
||||||
|
|
||||||
|
#### Scenario: Версия из более ранней доставки не откатывает витрину
|
||||||
|
|
||||||
|
- **WHEN** две доставки несут одну тренировку с равными наборами полей, и
|
||||||
|
свёрнута сперва более поздняя по журналу, затем более ранняя
|
||||||
|
- **THEN** в хранилище лежит версия из более поздней доставки
|
||||||
|
- **AND** тот же исход даёт свёртка в обратном порядке
|
||||||
|
|
||||||
|
#### Scenario: Обеднённая версия сохранённую не затирает
|
||||||
|
|
||||||
|
- **WHEN** та же тренировка приезжает повторно **без** `route`, который был у
|
||||||
|
сохранённой, **и** с изменившимися значениями общих полей
|
||||||
|
- **THEN** в хранилище остаётся сохранённая версия
|
||||||
|
- **AND** факт учитывается счётчиком и записью `WARN` с идентификатором
|
||||||
|
тренировки
|
||||||
|
|
||||||
|
#### Scenario: Усечённый маршрут сохранённый не затирает
|
||||||
|
|
||||||
|
- **WHEN** та же тренировка приезжает повторно с тем же набором полей, но
|
||||||
|
`route` короче сохранённого
|
||||||
|
- **THEN** в хранилище остаётся сохранённая версия
|
||||||
|
- **AND** факт учитывается тем же счётчиком
|
||||||
|
|
||||||
|
#### Scenario: Две версии одной сущности в одном теле
|
||||||
|
|
||||||
|
- **WHEN** тело содержит два элемента секции с одним `id`
|
||||||
|
- **THEN** исход не зависит от их порядка в массиве
|
||||||
|
- **AND** счётчик различающихся версий тоже не зависит от их порядка
|
||||||
|
|
||||||
|
#### Scenario: Составной ключ не даёт коллизии отпечатка
|
||||||
|
|
||||||
|
- **WHEN** две витрины различаются только тем, где проходит граница между родом
|
||||||
|
и идентификатором записи
|
||||||
|
- **THEN** отпечатки не совпадают
|
||||||
|
|
||||||
|
#### Scenario: Несравнимые наборы полей не объединяются
|
||||||
|
|
||||||
|
- **WHEN** приехавшая версия несёт содержательный ключ, которого нет у
|
||||||
|
сохранённой, и теряет содержательный ключ, который у сохранённой есть
|
||||||
|
- **THEN** в хранилище остаётся сохранённая версия
|
||||||
|
- **AND** факт учитывается тем же счётчиком
|
||||||
|
|
||||||
|
#### Scenario: Повторная свёртка того же журнала состояния не меняет
|
||||||
|
|
||||||
|
- **WHEN** те же доставки сворачиваются повторно в том же порядке
|
||||||
|
- **THEN** содержимое сущностей не меняется
|
||||||
|
|
||||||
|
### Requirement: Отпечаток витрины покрывает все её сущности
|
||||||
|
|
||||||
|
Отпечаток витрины SHALL включать тренировки и записи наравне с часовыми
|
||||||
|
объектами: он единственный оракул сходимости пересборки, и отпечаток одних
|
||||||
|
объектов давал бы «состояние сошлось» при разъехавшихся тренировках — то есть
|
||||||
|
ломался бы молча ровно тем изменением, которое добавляет данные.
|
||||||
|
|
||||||
|
В отпечаток идут координаты сущности и хеш её содержимого. Значений он
|
||||||
|
раскрывать MUST NOT — как и для точек.
|
||||||
|
|
||||||
|
Порядок обхода SHALL быть детерминированным и заданным запросом, а не порядком
|
||||||
|
строк в файле базы. Строки разных разделов SHALL различаться константным
|
||||||
|
признаком раздела: без него строка одного раздела может совпасть со строкой
|
||||||
|
другого, и два разных состояния дали бы один отпечаток. По той же причине
|
||||||
|
составной ключ SHALL идти в отпечаток **отдельными полями с собственными
|
||||||
|
длинами**, а не склейкой: склейка выполняется до взятия длины, и пара
|
||||||
|
(`a`, `b/c`) даёт ту же строку, что (`a/b`, `c`).
|
||||||
|
|
||||||
|
Границу оракула стоит назвать вслух: в отпечаток идут координаты и хеш
|
||||||
|
содержимого, а **колонки заголовка** сущности (имя, конец интервала, офсет,
|
||||||
|
длительность) — нет. Они производны от содержимого, поэтому их расхождение
|
||||||
|
означает изменившийся код извлечения заголовка, а не разъехавшееся состояние; но
|
||||||
|
«отпечатки совпали» не является утверждением о них.
|
||||||
|
|
||||||
|
Все разделы SHALL читаться **одним снимком** базы. Отпечаток рабочей витрины
|
||||||
|
снимается под живым приёмом, и запросы вне общей транзакции чтения дали бы смесь
|
||||||
|
«объекты до» и «тренировки после» — то есть ложное расхождение у единственного
|
||||||
|
оракула сходимости.
|
||||||
|
|
||||||
|
#### Scenario: Расхождение тренировок видно в отпечатке
|
||||||
|
|
||||||
|
- **WHEN** две витрины совпадают по часовым объектам, но содержимое одной
|
||||||
|
тренировки различается
|
||||||
|
- **THEN** отпечатки не совпадают
|
||||||
|
|
||||||
|
#### Scenario: Отпечаток одинаков при одинаковом содержимом
|
||||||
|
|
||||||
|
- **WHEN** та же витрина собрана повторно из того же журнала
|
||||||
|
- **THEN** отпечаток совпадает
|
||||||
|
|
||||||
|
#### Scenario: Запись во время снятия отпечатка не смешивает разделы
|
||||||
|
|
||||||
|
- **WHEN** отпечаток снимается, а параллельно коммитится свёртка
|
||||||
|
- **THEN** отпечаток отражает одно состояние базы, а не смесь снимков
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Значения точек не попадают в логи
|
||||||
|
|
||||||
|
Данные о здоровье чувствительнее токенов. Система MUST NOT писать значения
|
||||||
|
точек, содержимое сущностей и тела доставок в записи лога уровня выше `DEBUG`.
|
||||||
|
|
||||||
|
Содержимое сущности здесь не менее чувствительно, чем значение точки, а местами
|
||||||
|
более: маршрут тренировки — это геотрек до дома, а `labels` и `associations`
|
||||||
|
записи состояния разума — эмоциональные метки. Разрешены **координаты**:
|
||||||
|
идентификатор и род сущности, метка времени, идентификатор доставки — они
|
||||||
|
описывают, что случилось, а не что измерено.
|
||||||
|
|
||||||
|
Непокрытые секции называются в логе **именами ключей**: имя секции — это форма
|
||||||
|
пакета, а не измерение. Содержимое секции в лог не попадает ни при каком уровне
|
||||||
|
выше `DEBUG`. Имена идут структурным атрибутом, а не склейкой в текст сообщения:
|
||||||
|
кодировщик экранирует управляющие символы, и имя из чужого тела не разрывает
|
||||||
|
построчный разбор логов. То же относится к идентификатору сущности: он приходит
|
||||||
|
из чужого тела и ограничен по длине при разборе.
|
||||||
|
|
||||||
|
Частичный разбор уровня записи не повышает: `partial` — установившееся состояние
|
||||||
|
половины потока (53 доставки из 118), и постоянный `WARN` обесценил бы уровень.
|
||||||
|
Повышает уровень другое — срабатывание границ списка: тело с сотнями секций или
|
||||||
|
с именем длиннее предела на HAE не похоже вовсе.
|
||||||
|
|
||||||
|
#### Scenario: Разбор доставки логируется без значений
|
||||||
|
|
||||||
|
- **WHEN** доставка разобрана
|
||||||
|
- **THEN** запись лога содержит счётчики (метрик, точек, объектов, сущностей) и
|
||||||
|
идентификатор доставки
|
||||||
|
- **AND** не содержит ни значений точек, ни имён устройств
|
||||||
|
|
||||||
|
#### Scenario: Удержанная обеднённая версия логируется координатами
|
||||||
|
|
||||||
|
- **WHEN** приехавшая версия сущности отклонена как теряющая содержание
|
||||||
|
- **THEN** запись `WARN` содержит идентификатор и род сущности
|
||||||
|
- **AND** не содержит ни точек маршрута, ни того, какие поля потерялись
|
||||||
|
|
||||||
|
#### Scenario: Непокрытые секции названы именами ключей
|
||||||
|
|
||||||
|
- **WHEN** доставка содержит непокрытую секцию
|
||||||
|
- **THEN** запись лога содержит имена непокрытых ключей отдельным атрибутом
|
||||||
|
- **AND** не содержит ничего из содержимого этих секций
|
||||||
|
- **AND** уровень записи из-за одной лишь частичности не повышается
|
||||||
|
|
||||||
|
#### Scenario: Границы списка сработали
|
||||||
|
|
||||||
|
- **WHEN** список непокрытых ключей усечён по числу имён или по длине имени
|
||||||
|
- **THEN** запись лога имеет уровень `WARN`
|
||||||
|
- **AND** содержит число отброшенных имён
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
## 1. Фикстуры на реальных пакетах
|
||||||
|
|
||||||
|
- [x] 1.1 Расширить `tmp/research/fixtures.py`: вычистка UUID (`id`), меток
|
||||||
|
RFC 3339, `route[].timestamp` и словарных значений `stateOfMind`
|
||||||
|
(`kind`, `valenceClassification`, `labels`, `associations`) при
|
||||||
|
сохранении формы литерала, структуры и порядка ключей
|
||||||
|
- [x] 1.2 Собрать `internal/hae/testdata/workout_route.json` — уличная
|
||||||
|
тренировка с маршрутом (проверяет дословность маршрута и внутренних рядов)
|
||||||
|
- [x] 1.3 Собрать `internal/hae/testdata/workout_indoor.json` — тренировка без
|
||||||
|
маршрута, с полями, которых нет у уличной (`temperature`, `humidity`,
|
||||||
|
`intensity`)
|
||||||
|
- [x] 1.4 Собрать `internal/hae/testdata/state_of_mind.json` — состояние разума
|
||||||
|
(RFC 3339, отсутствие `source`)
|
||||||
|
- [x] 1.5 Дописать в `handmade_edge.json` случаи, которых поток не даёт:
|
||||||
|
сущность без `id`, с пустым и со слишком длинным `id`, с неразбираемой
|
||||||
|
меткой, с неразбираемым `end`, с нечисловой длительностью, два элемента с
|
||||||
|
одним `id` в одном теле
|
||||||
|
- [x] 1.6 Обновить `internal/hae/testdata/README.md`: новые файлы и что именно
|
||||||
|
вычищено
|
||||||
|
|
||||||
|
## 2. Разбор (`internal/hae`)
|
||||||
|
|
||||||
|
- [x] 2.1 Множество покрытых секций: `metrics`, `workouts`, `stateOfMind` —
|
||||||
|
одно объявление на разбор и на перечисление непокрытых
|
||||||
|
- [x] 2.2 Типы `Workout` и `Record` в `Result`; счётчики `SkippedNoID`,
|
||||||
|
`SkippedNoTime` для сущностей
|
||||||
|
- [x] 2.3 `parseEntityTime`: формат HAE, затем RFC 3339; офсет из разобранной
|
||||||
|
зоны. Парсер точек остаётся строгим — причина записана в спеке
|
||||||
|
- [x] 2.4 Разбор `workouts`: заголовок (`id`, `name`, `start`, `end`,
|
||||||
|
`duration`), содержимое — исходные байты элемента; `end` нечитаем →
|
||||||
|
равен началу; длительность отсутствует → не заполнена (не ноль)
|
||||||
|
- [x] 2.5 Разбор `stateOfMind` в записи рода `stateOfMind` (имя секции дословно)
|
||||||
|
- [x] 2.6 `Parse` отдаёт сущности и при отсутствии секции `metrics`; при
|
||||||
|
ошибке (обрыв тела, невыводимый слой) не отдаёт ни точек, ни сущностей
|
||||||
|
- [x] 2.7 Повтор ключа покрытой секции даёт объединение; повтор члена `data`
|
||||||
|
**накапливает** результаты, а не присваивает последний (существующий
|
||||||
|
дефект `decodeEnvelope`)
|
||||||
|
- [x] 2.8 Предел длины `id`: сущность сверх него пропускается тем же счётчиком
|
||||||
|
- [x] 2.9 Тесты на фикстурах: маршрут дословно, пульс тренировки не стал
|
||||||
|
метрикой, оба формата времени, пропуски со счётчиками
|
||||||
|
- [x] 2.10 Тест: доставка из одних тренировок и из одного `stateOfMind`
|
||||||
|
непокрытых ключей не даёт; из одного `ecg` — даёт
|
||||||
|
|
||||||
|
## 3. Схема (`internal/store/migrations`)
|
||||||
|
|
||||||
|
- [x] 3.1 Миграция `00007_workout_record.sql`: таблицы `workout` и `record`
|
||||||
|
(провенанс `delivery_id` + `delivery_received_at`, `content_hash`,
|
||||||
|
nullable `duration_sec`), индексы по времени, перевод `partial`-доставок
|
||||||
|
с ключами `workouts` и `stateOfMind` в `pending`
|
||||||
|
- [x] 3.2 Проверить, что перевод в `pending` отбирает строки точно (по элементу
|
||||||
|
JSON-массива, а не по подстроке тела)
|
||||||
|
- [x] 3.3 Обновить `docs/database.md`: обе таблицы, смысл колонок, ключ
|
||||||
|
`род + id`, провенанс, `NULL` у длительности, офсет `0` у `stateOfMind`
|
||||||
|
|
||||||
|
## 4. Хранение (`internal/store`, `internal/canon`)
|
||||||
|
|
||||||
|
- [x] 4.1 `canon`: сравнение множеств содержательных ключей **без** условия о
|
||||||
|
совпадении значений (поверх существующего `relateKeys`) плюс сравнение
|
||||||
|
длин верхнеуровневых массивов
|
||||||
|
- [x] 4.2 `Incoming{Points, Workouts, Records}` и `Merge` вместо `MergePoints`:
|
||||||
|
одна транзакция на доставку
|
||||||
|
- [x] 4.3 Правило замены версии: хеш-детектор без чтения блоба, условие «не
|
||||||
|
теряет содержания», тай-брейк по позиции журнала `(received_at, id)`,
|
||||||
|
внутридоставочный тай-брейк по канонической форме, счётчик и координаты
|
||||||
|
для `WARN`
|
||||||
|
- [x] 4.4 Чтение и запись тренировки и записи; содержимое — сжатый блоб; хеш
|
||||||
|
приехавшей считается один раз на доставку, до входа в транзакцию
|
||||||
|
- [x] 4.5 `Fingerprint` покрывает тренировки и записи, читает одной read-only
|
||||||
|
транзакцией, строки разделов различаются константным признаком
|
||||||
|
- [x] 4.6 Тесты: замещение маршрутом; досчёт при том же наборе полей; обеднённая
|
||||||
|
версия **с изменившимися значениями** не затирает; усечённый маршрут не
|
||||||
|
затирает; несравнимые наборы; повтор не пишет; две версии в одном теле;
|
||||||
|
перестановка **трёх** версий в двух порядках подачи (конвенция
|
||||||
|
`docs/conventions.md`)
|
||||||
|
- [x] 4.7 Тест: отпечаток расходится при расхождении одной тренировки
|
||||||
|
|
||||||
|
## 5. Свёртка и пересборка (`internal/fold`, `internal/replay`, `cmd`)
|
||||||
|
|
||||||
|
- [x] 5.1 `Stats` несёт счётчики сущностей; свёртка зовёт `Merge` один раз;
|
||||||
|
счётчики доезжают до лога без ручного копирования (или это покрыто тестом)
|
||||||
|
- [x] 5.2 Единственный логирующий чекпоинт: атрибуты сущностей, ветка `WARN`
|
||||||
|
для удержанной обеднённой версии — координатами, без содержимого
|
||||||
|
- [x] 5.3 Отчёт `reindex`: «до и после» по тренировкам и записям, «покрыта новая
|
||||||
|
секция» в перечне ожидаемых классов расхождения
|
||||||
|
- [x] 5.4 Тест: содержимое сущности в лог не попадает
|
||||||
|
|
||||||
|
## 6. Сходимость и проверка на живом архиве
|
||||||
|
|
||||||
|
- [x] 6.1 `task gate` — зелёный
|
||||||
|
- [x] 6.2 `task verify:archive` — прогон всего `./data/raw`, повтор даёт то же
|
||||||
|
состояние; он же оракул того, что живой приём и пересборка применяют к
|
||||||
|
сущностям один порядок
|
||||||
|
- [x] 6.3 Проверка на копии рабочей базы в отдельном каталоге данных: миграция
|
||||||
|
накатывается, `partial`-доставки пересворачиваются, тренировки и записи
|
||||||
|
появляются
|
||||||
|
|
||||||
|
## 7. Документация и беклог
|
||||||
|
|
||||||
|
- [x] 7.1 `docs/architecture.md` — раздел «Тренировки и прочие секции»: правило
|
||||||
|
замены версии с обоснованием, отвергнутые варианты, предел `import`;
|
||||||
|
**плюс блок схемы БД** (`record(kind, id)`, сжатый блоб, `content_hash`,
|
||||||
|
провенанс)
|
||||||
|
- [x] 7.2 `docs/conventions.md` — строка про ключ `record` устарела, поправить
|
||||||
|
- [x] 7.3 `docs/local-research.md` — находка о поведении тренировки при
|
||||||
|
переприсылке (числа замера) и пересчёт находки 50 на 118 доставок
|
||||||
|
- [x] 7.4 Беклог: задача про идентичность тренировок при импорте родного
|
||||||
|
экспорта (в `export.xml` `id` нет — `dogsheep` считает hash_id);
|
||||||
|
`retenshen-syrogo-arhiva` — предусловие про `stateOfMind` снова открыто;
|
||||||
|
отметить в `read-api-tochki`, что отдача тренировок и записей входит в
|
||||||
|
неё; уточнить `proverka-novyh-sekcij` — модель заложена, остались пять
|
||||||
|
секций
|
||||||
|
- [x] 7.5 Удалить `docs/backlog/trenirovki-i-zapisi.md` и строку индекса
|
||||||
|
|
||||||
|
## 8. Приёмочные критерии (рубрика ревью)
|
||||||
|
|
||||||
|
- [x] 8.1 Тренировка с маршрутом переживает круг «разбор → хранение → чтение»
|
||||||
|
побайтово
|
||||||
|
- [x] 8.2 Повторная свёртка того же журнала не меняет отпечатка витрины
|
||||||
|
- [x] 8.3 Ни один путь не пишет содержимое сущности в лог выше `DEBUG`
|
||||||
|
- [x] 8.4 Доставка из одних тренировок получает `parsed`, а не `partial`
|
||||||
|
- [x] 8.5 Доставка с непокрытой секцией по-прежнему `partial`, и её тело
|
||||||
|
ретеншену трогать нельзя
|
||||||
|
- [x] 8.6 Исход правила замены не зависит от порядка свёртки в пунктах 2–4
|
||||||
|
правила; зависимость в пункте 5 наблюдаема счётчиком
|
||||||
|
- [x] 8.7 У каждого класса пропуска при разборе сущности — свой счётчик, и
|
||||||
|
пропуск одного элемента не уносит соседей
|
||||||
+239
-23
@@ -159,9 +159,29 @@ Read API «самый мелкий слой, покрывающий диапаз
|
|||||||
|
|
||||||
### Requirement: Разбор форматов времени
|
### Requirement: Разбор форматов времени
|
||||||
|
|
||||||
Система SHALL разбирать метку точки формата `2026-07-31 21:03:51 +0300` и
|
Система SHALL разбирать метку формата `2026-07-31 21:03:51 +0300` и приводить
|
||||||
приводить её к UTC, сохраняя офсет исходной зоны. В секции `data.metrics`
|
её к UTC, сохраняя офсет исходной зоны. В секции `data.metrics` других форматов
|
||||||
других форматов меток не встречается.
|
меток не встречается.
|
||||||
|
|
||||||
|
Система SHALL разбирать вторым форматом RFC 3339 в UTC
|
||||||
|
(`2026-07-31T18:03:51Z`): им приходят метки секции `data.stateOfMind`, тогда
|
||||||
|
как тренировки и метрики шлют первый формат. Оба формата SHALL приниматься **у
|
||||||
|
любой** метки сущности, а не приписываться секции жёстко: формы однозначны и не
|
||||||
|
пересекаются, а HAE выравнивает секции между собой по ходу своих обновлений —
|
||||||
|
`stateOfMind` уже шлёт стабильные коды HealthKit там, где старые секции шлют
|
||||||
|
переводы. Приписанный секции формат ломался бы молча в день такого выравнивания.
|
||||||
|
|
||||||
|
Метка RFC 3339 в UTC даёт офсет `0`, и это MUST означать «источник прислал
|
||||||
|
UTC», а не «человек находился в нулевой зоне»: местной зоны у секции
|
||||||
|
`stateOfMind` в потоке нет вовсе.
|
||||||
|
|
||||||
|
Метка **точки** при этом остаётся строгой — один формат, — и асимметрия
|
||||||
|
намеренная. По метке точки выводится слой, причём по метке в **исходной зоне**;
|
||||||
|
терпимость к RFC 3339 означала бы, что метка в UTC тихо портит выравнивание и
|
||||||
|
часовая выгрузка складывается с минутной (наблюдалось: удвоение суммы за час).
|
||||||
|
У сущности слоя нет, и терять на строгости нечего, а у точки строгий парсер
|
||||||
|
отдаёт непонятую метку в счётчик пропусков — тело остаётся в архиве, и
|
||||||
|
пересборка вернёт его, когда формат станет известен.
|
||||||
|
|
||||||
Unix-эпоха дробным числом (`1785446196.4132624`) встречается **внутри**
|
Unix-эпоха дробным числом (`1785446196.4132624`) встречается **внутри**
|
||||||
`heartbeatSeries` и меткой точки не является. Система MUST NOT преобразовывать
|
`heartbeatSeries` и меткой точки не является. Система MUST NOT преобразовывать
|
||||||
@@ -169,16 +189,24 @@ Unix-эпоха дробным числом (`1785446196.4132624`) встреч
|
|||||||
и обратно не гарантирует дословности, а серия составляет 93% объёма метрики
|
и обратно не гарантирует дословности, а серия составляет 93% объёма метрики
|
||||||
`heart_rate_variability`.
|
`heart_rate_variability`.
|
||||||
|
|
||||||
RFC 3339 (`2026-07-31T18:03:51Z`) в этой дельте не нормируется: он встречается
|
Время внутри маршрута тренировки (`route[].timestamp`) меткой сущности тоже не
|
||||||
только в `data.stateOfMind`, которая выведена из scope. Требование к нему
|
является и MUST проходить дословно, не разбираясь.
|
||||||
появится вместе с задачей про секции с собственными `id` — вместе с данными,
|
|
||||||
на которых его можно проверить.
|
|
||||||
|
|
||||||
#### Scenario: Локальное время со смещением
|
#### Scenario: Локальное время со смещением
|
||||||
|
|
||||||
- **WHEN** метка имеет вид `2026-07-31 21:03:51 +0300`
|
- **WHEN** метка имеет вид `2026-07-31 21:03:51 +0300`
|
||||||
- **THEN** точка получает время в UTC и офсет `+10800` секунд
|
- **THEN** точка получает время в UTC и офсет `+10800` секунд
|
||||||
|
|
||||||
|
#### Scenario: RFC 3339 в UTC
|
||||||
|
|
||||||
|
- **WHEN** метка сущности имеет вид `2026-07-31T18:03:51Z`
|
||||||
|
- **THEN** сущность получает время в UTC и офсет `0`
|
||||||
|
|
||||||
|
#### Scenario: Тренировка со временем в формате метрик
|
||||||
|
|
||||||
|
- **WHEN** тренировка несёт `start` вида `2026-08-01 10:04:31 +0300`
|
||||||
|
- **THEN** сущность получает время в UTC и офсет `+10800` секунд
|
||||||
|
|
||||||
#### Scenario: Время внутри серии ударов
|
#### Scenario: Время внутри серии ударов
|
||||||
|
|
||||||
- **WHEN** точка метрики `heart_rate_variability` содержит `heartbeatSeries`
|
- **WHEN** точка метрики `heart_rate_variability` содержит `heartbeatSeries`
|
||||||
@@ -186,6 +214,12 @@ RFC 3339 (`2026-07-31T18:03:51Z`) в этой дельте не нормируе
|
|||||||
- **AND** серия не разворачивается в отдельные точки
|
- **AND** серия не разворачивается в отдельные точки
|
||||||
- **AND** эпоха внутри серии не разбирается и не преобразуется
|
- **AND** эпоха внутри серии не разбирается и не преобразуется
|
||||||
|
|
||||||
|
#### Scenario: Время внутри маршрута не разбирается
|
||||||
|
|
||||||
|
- **WHEN** тренировка содержит `route` с полем `timestamp` у каждой точки
|
||||||
|
- **THEN** точки маршрута сохраняются исходными байтами
|
||||||
|
- **AND** их метки не разбираются и не преобразуются
|
||||||
|
|
||||||
### Requirement: Разделение схем под одним именем метрики
|
### Requirement: Разделение схем под одним именем метрики
|
||||||
|
|
||||||
Система SHALL разводить на разные имена метрики те схемы, которые Health Auto
|
Система SHALL разводить на разные имена метрики те схемы, которые Health Auto
|
||||||
@@ -270,32 +304,38 @@ Export шлёт под одним именем, чтобы одно имя оз
|
|||||||
MUST NOT удерживаться после того, как разбор прошёл мимо неё: тела доходят до
|
MUST NOT удерживаться после того, как разбор прошёл мимо неё: тела доходят до
|
||||||
42 МиБ, и удержание кучи здесь — часть контракта, а не деталь реализации.
|
42 МиБ, и удержание кучи здесь — часть контракта, а не деталь реализации.
|
||||||
|
|
||||||
Покрытым сегодня является ровно один ключ — `metrics`. Разбор и перечисление
|
Покрытых ключей сегодня три — `metrics`, `workouts` и `stateOfMind`. Разбор и
|
||||||
MUST ходить по одному объявленному множеству покрытых имён: состояние «секция
|
перечисление MUST ходить по одному объявленному множеству покрытых имён:
|
||||||
разбирается, но числится непокрытой» невыразимо по построению.
|
состояние «секция разбирается, но числится непокрытой» невыразимо по построению.
|
||||||
|
|
||||||
Непокрытым ключ считается независимо от того, что лежит внутри: содержимое не
|
Непокрытым ключ считается независимо от того, что лежит внутри: содержимое не
|
||||||
интерпретируется, поэтому и о пустоте секции разбор честно ничего не знает.
|
интерпретируется, поэтому и о пустоте секции разбор честно ничего не знает.
|
||||||
Измерено на живом архиве — пустых секций HAE не присылает ни разу (99 доставок).
|
Измерено на живом архиве — пустых секций HAE не присылает ни разу (118 доставок).
|
||||||
|
|
||||||
Список SHALL быть каноничен: имена отсортированы, повторов нет. Порядок ключей в
|
Список SHALL быть каноничен: имена отсортированы, повторов нет. Порядок ключей в
|
||||||
JSON от HAE нестабилен, а значение уезжает в базу и сравнивается между
|
JSON от HAE нестабилен, а значение уезжает в базу и сравнивается между
|
||||||
доставками.
|
доставками.
|
||||||
|
|
||||||
Отсутствие непокрытых ключей и отсутствие секции `metrics` — разные события, и
|
Отсутствие непокрытых ключей и отсутствие секции `metrics` — разные события, и
|
||||||
оба нормальны: половина потока состоит из доставок без метрик вовсе (48 из 99).
|
оба нормальны: половина потока состоит из доставок без метрик вовсе (53 из 118).
|
||||||
|
|
||||||
#### Scenario: Незнакомая секция попадает в список непокрытых
|
#### Scenario: Незнакомая секция попадает в список непокрытых
|
||||||
|
|
||||||
- **WHEN** тело содержит `data.workouts` наряду с `data.metrics`
|
- **WHEN** тело содержит `data.ecg` наряду с `data.metrics`
|
||||||
- **THEN** разбор возвращает `workouts` в списке непокрытых ключей
|
- **THEN** разбор возвращает `ecg` в списке непокрытых ключей
|
||||||
- **AND** точки секции `metrics` разбираются как обычно
|
- **AND** точки секции `metrics` разбираются как обычно
|
||||||
|
|
||||||
#### Scenario: Доставка без метрик разбирается и не теряется
|
#### Scenario: Доставка из одних тренировок непокрытых ключей не даёт
|
||||||
|
|
||||||
|
- **WHEN** тело содержит только `data.workouts`
|
||||||
|
- **THEN** разбор завершается без ошибки, точек нет, тренировки разобраны
|
||||||
|
- **AND** список непокрытых ключей пуст
|
||||||
|
|
||||||
|
#### Scenario: Доставка из одного состояния разума непокрытых ключей не даёт
|
||||||
|
|
||||||
- **WHEN** тело содержит только `data.stateOfMind`
|
- **WHEN** тело содержит только `data.stateOfMind`
|
||||||
- **THEN** разбор завершается без ошибки, точек нет
|
- **THEN** разбор завершается без ошибки, записи разобраны
|
||||||
- **AND** `stateOfMind` возвращается в списке непокрытых ключей
|
- **AND** список непокрытых ключей пуст
|
||||||
|
|
||||||
#### Scenario: Доставка из одних метрик непокрытых ключей не даёт
|
#### Scenario: Доставка из одних метрик непокрытых ключей не даёт
|
||||||
|
|
||||||
@@ -345,15 +385,26 @@ JSON от HAE нестабилен, а значение уезжает в баз
|
|||||||
### Requirement: Отказ разбора остаётся всё или ничего
|
### Requirement: Отказ разбора остаётся всё или ничего
|
||||||
|
|
||||||
Разбор SHALL оставаться операцией «всё или ничего»: ошибка, встреченная
|
Разбор SHALL оставаться операцией «всё или ничего»: ошибка, встреченная
|
||||||
**после** того, как секция `metrics` уже разобрана (обрезанное тело, мусор в
|
**после** того, как покрытая секция уже разобрана (обрезанное тело, мусор в
|
||||||
следующем члене), MUST NOT оставлять точки в результате — доставка считается
|
следующем члене), MUST NOT оставлять в результате ни точек, ни сущностей —
|
||||||
неразобранной целиком.
|
доставка считается неразобранной целиком.
|
||||||
|
|
||||||
Иначе часть точек оказалась бы в витрине под статусом, по которому доставку
|
Иначе часть данных оказалась бы в витрине под статусом, по которому доставку
|
||||||
никто не подберёт, и свёртка перестала бы быть детерминированной по журналу.
|
никто не подберёт, и свёртка перестала бы быть детерминированной по журналу.
|
||||||
|
|
||||||
Повтор ключа `metrics` в одном объекте `data` SHALL давать объединение секций, а
|
Правило SHALL распространяться и на невыводимый слой: доставка, у которой есть
|
||||||
не победу последней: молча терять точки нельзя.
|
метрики, но слой их не определяется, не сохраняет и своих сущностей, хотя слоя
|
||||||
|
у сущности нет. Соблазн «сущности от слоя не зависят, запишем их» ломает то же
|
||||||
|
«всё или ничего» — доставка получила бы `failed` при частично записанной
|
||||||
|
витрине, и повторная свёртка перестала бы быть no-op. Цена названа вслух: если
|
||||||
|
такая доставка когда-нибудь принесёт `stateOfMind`, его записи доедут не сразу,
|
||||||
|
а пересборкой; тело при этом остаётся в архиве, и `failed` ретеншену трогать
|
||||||
|
нельзя.
|
||||||
|
|
||||||
|
Повтор ключа покрытой секции в одном объекте `data` SHALL давать объединение
|
||||||
|
секций, а не победу последней: молча терять данные нельзя. То же SHALL
|
||||||
|
относиться к повтору самого члена `data` в теле — результаты **накапливаются**,
|
||||||
|
включая список непокрытых ключей.
|
||||||
|
|
||||||
#### Scenario: Тело оборвано после секции метрик
|
#### Scenario: Тело оборвано после секции метрик
|
||||||
|
|
||||||
@@ -361,8 +412,173 @@ JSON от HAE нестабилен, а значение уезжает в баз
|
|||||||
оборван
|
оборван
|
||||||
- **THEN** разбор завершается ошибкой и точек не отдаёт
|
- **THEN** разбор завершается ошибкой и точек не отдаёт
|
||||||
|
|
||||||
|
#### Scenario: Тело оборвано после секции тренировок
|
||||||
|
|
||||||
|
- **WHEN** тело содержит целую секцию `workouts`, а следующий член `data`
|
||||||
|
оборван
|
||||||
|
- **THEN** разбор завершается ошибкой и сущностей не отдаёт
|
||||||
|
|
||||||
|
#### Scenario: Невыводимый слой не сохраняет и сущностей
|
||||||
|
|
||||||
|
- **WHEN** доставка несёт метрики, слой которых не определяется, и вместе с
|
||||||
|
ними секцию `stateOfMind`
|
||||||
|
- **THEN** разбор завершается ошибкой, ни точек, ни записей не отдаёт
|
||||||
|
- **AND** список непокрытых ключей переживает отказ
|
||||||
|
|
||||||
#### Scenario: Секция метрик встречается дважды
|
#### Scenario: Секция метрик встречается дважды
|
||||||
|
|
||||||
- **WHEN** объект `data` содержит два ключа `metrics`
|
- **WHEN** объект `data` содержит два ключа `metrics`
|
||||||
- **THEN** точки обеих секций попадают в результат
|
- **THEN** точки обеих секций попадают в результат
|
||||||
|
|
||||||
|
#### Scenario: Секция тренировок встречается дважды
|
||||||
|
|
||||||
|
- **WHEN** объект `data` содержит два ключа `workouts`
|
||||||
|
- **THEN** сущности обеих секций попадают в результат
|
||||||
|
|
||||||
|
#### Scenario: Член `data` встречается дважды
|
||||||
|
|
||||||
|
- **WHEN** тело содержит два члена `data`, из которых первый несёт непокрытую
|
||||||
|
секцию, а второй — покрытую
|
||||||
|
- **THEN** данные покрытой секции попадают в результат
|
||||||
|
- **AND** имя непокрытой секции остаётся в списке непокрытых ключей
|
||||||
|
|
||||||
|
### Requirement: Разбор секций с собственными идентификаторами
|
||||||
|
|
||||||
|
Система SHALL разбирать секции тела, элементы которых несут собственный `id`, в
|
||||||
|
**сущности**, а не в точки: у сущности нет ни слоя, ни координатного ключа
|
||||||
|
`метрика + слой + начало + конец` — её адресует сам `id`.
|
||||||
|
|
||||||
|
Покрываются две такие секции: `data.workouts` и `data.stateOfMind`. Секции
|
||||||
|
`ecg`, `symptoms`, `cycleTracking`, `medications` и `heartRateNotifications`
|
||||||
|
покрытыми MUST NOT становиться: живой поток не приносил их ни разу (118
|
||||||
|
доставок), их форма никем не наблюдалась, а полнота покрытия HealthKit ради
|
||||||
|
полноты целью проекта не является. Они остаются в списке непокрытых, и доставка
|
||||||
|
с ними остаётся `partial`.
|
||||||
|
|
||||||
|
Из тренировки разбор SHALL брать только то, по чему потом идёт выборка:
|
||||||
|
идентификатор, имя, начало, конец, офсет исходной зоны и длительность. Всё
|
||||||
|
остальное — включая маршрут, внутренние ряды (`heartRateData`,
|
||||||
|
`activeEnergy`, `heartRateRecovery`) и сводки — MUST храниться содержимым
|
||||||
|
сущности **дословно**, теми же байтами, какими пришло. Раскладывать структуру
|
||||||
|
тренировки по колонкам значило бы решить за Apple, что в ней главное: сводки
|
||||||
|
дублируют ряды (`distance` — это сумма `walkingAndRunningDistance`), а набор
|
||||||
|
полей зависит от типа тренировки (у уличной есть `route`, `avgSpeed`,
|
||||||
|
`flightsClimbed`, у домашней — `temperature`, `humidity`, `intensity`).
|
||||||
|
|
||||||
|
Длительность SHALL браться из тела, а не вычисляться из начала и конца: HAE
|
||||||
|
шлёт `91.746` секунды при интервале в 91 секунду, и вычисленное значение молча
|
||||||
|
разошлось бы с присланным. Отсутствие или нечисловое значение длительности
|
||||||
|
сущность MUST NOT отбрасывать; такая длительность SHALL быть выражена
|
||||||
|
отсутствием значения, а не нулём — ноль является законной длительностью, и
|
||||||
|
потребитель не отличил бы «источник не прислал» от «измерено ноль».
|
||||||
|
|
||||||
|
Началом сущности SHALL быть `start`, при его отсутствии — `date`. Конец берётся
|
||||||
|
из `end`; при отсутствии или неразбираемости конца он SHALL равняться началу, а
|
||||||
|
истина остаётся в содержимом. Вырождение интервала здесь безопасно, в отличие от
|
||||||
|
точки: ключ сущности — `id`, схлопывать координаты нечем. Офсет исходной зоны
|
||||||
|
SHALL браться из начала: колонка одна, а тренировка через смену зоны дала бы
|
||||||
|
два разных.
|
||||||
|
|
||||||
|
Из записи разбор SHALL брать идентификатор, род секции, метку времени и офсет;
|
||||||
|
всё остальное хранится дословно. Род записи SHALL быть верхнеуровневым ключом
|
||||||
|
секции HAE **дословно** (`stateOfMind`, не `state_of_mind`): инвариант «форма
|
||||||
|
Apple не транслируется» относится и к именам секций.
|
||||||
|
|
||||||
|
Длина идентификатора SHALL быть ограничена, и сущность с более длинным `id`
|
||||||
|
SHALL пропускаться тем же счётчиком, что и сущность без `id`. Идентификатор
|
||||||
|
приходит из тела, которым отправитель управляет целиком, а уезжает и в ключ
|
||||||
|
таблицы, и в записи лога; правило то же, что уже действует для имён непокрытых
|
||||||
|
секций.
|
||||||
|
|
||||||
|
Ряд пульса **внутри** тренировки MUST NOT попадать в метрику `heart_rate`:
|
||||||
|
это разные сущности хранилища. Пульс приезжает дважды — в общем потоке метрик и
|
||||||
|
внутри тренировки, — и смешение задвоило бы ряд.
|
||||||
|
|
||||||
|
Сущность без `id` либо без разбираемой метки времени SHALL пропускаться со
|
||||||
|
счётчиком, не роняя разбор остального: тело остаётся в архиве, и доставку
|
||||||
|
подберёт пересборка, когда разбор научится её понимать.
|
||||||
|
|
||||||
|
Отсутствие покрытой секции в теле ошибкой быть MUST NOT: доставки из одних
|
||||||
|
метрик — большинство потока.
|
||||||
|
|
||||||
|
#### Scenario: Тренировка разбирается вместе с маршрутом
|
||||||
|
|
||||||
|
- **WHEN** тело содержит `data.workouts` с тренировкой, несущей `route`
|
||||||
|
- **THEN** разбор отдаёт сущность с идентификатором, именем, началом, концом,
|
||||||
|
офсетом и длительностью
|
||||||
|
- **AND** её содержимое несёт маршрут и внутренние ряды исходными байтами
|
||||||
|
|
||||||
|
#### Scenario: Ряд пульса тренировки не становится метрикой
|
||||||
|
|
||||||
|
- **WHEN** тренировка содержит `heartRateData`
|
||||||
|
- **THEN** точки этого ряда не попадают в точки метрик
|
||||||
|
- **AND** остаются внутри содержимого сущности
|
||||||
|
|
||||||
|
#### Scenario: Запись состояния разума разбирается
|
||||||
|
|
||||||
|
- **WHEN** тело содержит `data.stateOfMind` с элементом, несущим `id` и `start`
|
||||||
|
- **THEN** разбор отдаёт запись с родом `stateOfMind`, идентификатором, меткой
|
||||||
|
времени и содержимым исходными байтами
|
||||||
|
|
||||||
|
#### Scenario: Сущность без идентификатора пропускается
|
||||||
|
|
||||||
|
- **WHEN** элемент покрытой секции не несёт `id` либо `id` пуст
|
||||||
|
- **THEN** сущность в результат разбора не попадает
|
||||||
|
- **AND** факт учитывается счётчиком, а разбор остальных сущностей продолжается
|
||||||
|
|
||||||
|
#### Scenario: Элемент секции не является объектом
|
||||||
|
|
||||||
|
- **WHEN** элемент покрытой секции не разбирается как объект JSON
|
||||||
|
- **THEN** сущность в результат разбора не попадает
|
||||||
|
- **AND** факт учитывается **отдельным** счётчиком, а соседние сущности
|
||||||
|
разбираются как обычно
|
||||||
|
|
||||||
|
Отдельным, а не общим с «нет `id`»: доставка, где не разобрался сам элемент, —
|
||||||
|
это сменившаяся форма секции, а доставка без `id` — сменившаяся форма
|
||||||
|
идентификатора. Ронять из-за такого элемента всю доставку нельзя тем более:
|
||||||
|
`failed` фоновая свёртка не подбирает никогда, и вместе с одной кривой
|
||||||
|
тренировкой в него уехали бы записи `stateOfMind` той же доставки.
|
||||||
|
|
||||||
|
#### Scenario: Сущность без разбираемой метки времени пропускается
|
||||||
|
|
||||||
|
- **WHEN** элемент покрытой секции несёт `id`, но его метка времени не
|
||||||
|
разбирается ни одним из поддерживаемых форматов
|
||||||
|
- **THEN** сущность в результат разбора не попадает
|
||||||
|
- **AND** факт учитывается счётчиком
|
||||||
|
|
||||||
|
#### Scenario: Сущность со слишком длинным идентификатором пропускается
|
||||||
|
|
||||||
|
- **WHEN** элемент покрытой секции несёт `id` длиннее предела
|
||||||
|
- **THEN** сущность в результат разбора не попадает
|
||||||
|
- **AND** факт учитывается тем же счётчиком, что и отсутствие `id`
|
||||||
|
|
||||||
|
#### Scenario: Длительность берётся из тела, а не из интервала
|
||||||
|
|
||||||
|
- **WHEN** тренировка несёт `duration` равный `91.746` при интервале
|
||||||
|
`start`/`end` в 91 секунду
|
||||||
|
- **THEN** длительность сущности равна `91.746`
|
||||||
|
|
||||||
|
#### Scenario: Тренировка без длительности сохраняется без неё
|
||||||
|
|
||||||
|
- **WHEN** тренировка не несёт `duration` либо оно не является числом
|
||||||
|
- **THEN** сущность сохраняется, а её длительность остаётся незаполненной
|
||||||
|
- **AND** нулём она MUST NOT становиться
|
||||||
|
|
||||||
|
#### Scenario: Нечитаемый конец тренировки не отбрасывает её
|
||||||
|
|
||||||
|
- **WHEN** тренировка несёт `end`, который не разбирается
|
||||||
|
- **THEN** конец сущности равен её началу
|
||||||
|
- **AND** исходное значение остаётся в содержимом дословно
|
||||||
|
|
||||||
|
#### Scenario: Незнакомое поле тренировки переживает разбор
|
||||||
|
|
||||||
|
- **WHEN** тренировка несёт поле, которого разбор не знает
|
||||||
|
- **THEN** оно сохраняется в содержимом сущности дословно
|
||||||
|
- **AND** разбор не завершается ошибкой
|
||||||
|
|
||||||
|
#### Scenario: Непокрытая секция с собственными id остаётся непокрытой
|
||||||
|
|
||||||
|
- **WHEN** тело содержит `data.ecg`
|
||||||
|
- **THEN** `ecg` попадает в список непокрытых ключей
|
||||||
|
- **AND** сущностей из неё разбор не отдаёт
|
||||||
|
|
||||||
|
|||||||
@@ -309,6 +309,12 @@
|
|||||||
отпечатка** — рабочей витрины и пересобранной, — с прямым ответом, совпали они
|
отпечатка** — рабочей витрины и пересобранной, — с прямым ответом, совпали они
|
||||||
или нет.
|
или нет.
|
||||||
|
|
||||||
|
Счётчики «до и после» SHALL покрывать **каждую единицу хранения витрины**:
|
||||||
|
часовые объекты, тренировки и записи. Отпечаток отвечает «да/нет» за витрину
|
||||||
|
целиком, поэтому единица, которой нет в счётчиках, делает расхождение
|
||||||
|
безадресным: человек увидит «не совпало» при неизменившемся числе объектов и не
|
||||||
|
отличит появление двадцати семи тренировок от пропажи двух.
|
||||||
|
|
||||||
Отказы SHALL считаться **по классам**: слой не выводится, содержимое не
|
Отказы SHALL считаться **по классам**: слой не выводится, содержимое не
|
||||||
разбирается, работа отложена по обстоятельствам, всё прочее. Невыведенный слой
|
разбирается, работа отложена по обстоятельствам, всё прочее. Невыведенный слой
|
||||||
есть в каждом журнале и штатен; общий счётчик отправлял бы человека искать
|
есть в каждом журнале и штатен; общий счётчик отправлял бы человека искать
|
||||||
@@ -342,8 +348,14 @@
|
|||||||
пересобранной витрины и число доставок после прогона не измеряются вовсе —
|
пересобранной витрины и число доставок после прогона не измеряются вовсе —
|
||||||
печатать их сравнение значило бы выдать неизмеренное за измеренное, причём в
|
печатать их сравнение значило бы выдать неизмеренное за измеренное, причём в
|
||||||
единственном оракуле задачи. Ожидаемые классы расхождения (новые доставки за время прогона,
|
единственном оракуле задачи. Ожидаемые классы расхождения (новые доставки за время прогона,
|
||||||
непереносимый признак запечатанного часа, исправленный разбор) SHALL называться
|
непереносимый признак запечатанного часа, исправленный разбор, **покрытая
|
||||||
отдельно от самого факта расхождения.
|
разбором новая секция**) SHALL называться отдельно от самого факта расхождения.
|
||||||
|
|
||||||
|
Класс «покрыта новая секция» назван потому, что первый прогон после такого
|
||||||
|
изменения расходится **гарантированно** и штатно: витрина обзаводится единицами
|
||||||
|
хранения, которых в рабочей базе нет по построению. Не назвав его, отчёт
|
||||||
|
приучает человека игнорировать расхождение отпечатков — то есть обесценивает
|
||||||
|
оракул ровно тогда, когда по нему принимается необратимое решение.
|
||||||
|
|
||||||
**Исход команды.** Расхождение отпечатков отказом быть MUST NOT: после
|
**Исход команды.** Расхождение отпечатков отказом быть MUST NOT: после
|
||||||
исправления разбора оно ожидаемо и есть сам смысл пересборки. Отказ отдельной
|
исправления разбора оно ожидаемо и есть сам смысл пересборки. Отказ отдельной
|
||||||
@@ -373,6 +385,12 @@ SHALL идти в поток ошибок, а не смешиваться с о
|
|||||||
- **AND** прямо называет, совпали они или нет
|
- **AND** прямо называет, совпали они или нет
|
||||||
- **AND** называет, изменилось ли число доставок в рабочей базе за время прогона
|
- **AND** называет, изменилось ли число доставок в рабочей базе за время прогона
|
||||||
|
|
||||||
|
#### Scenario: Счётчики покрывают все единицы хранения
|
||||||
|
|
||||||
|
- **WHEN** пересборка завершилась
|
||||||
|
- **THEN** отчёт печатает «до и после» отдельно для часовых объектов,
|
||||||
|
тренировок и записей
|
||||||
|
|
||||||
#### Scenario: Расхождение отпечатков не является отказом
|
#### Scenario: Расхождение отпечатков не является отказом
|
||||||
|
|
||||||
- **WHEN** отпечаток пересобранной витрины отличается от рабочей, и при этом
|
- **WHEN** отпечаток пересобранной витрины отличается от рабочей, и при этом
|
||||||
|
|||||||
@@ -301,26 +301,39 @@ HTML-экранирования: `&`, `<` и `>` внутри точки обя
|
|||||||
### Requirement: Значения точек не попадают в логи
|
### Requirement: Значения точек не попадают в логи
|
||||||
|
|
||||||
Данные о здоровье чувствительнее токенов. Система MUST NOT писать значения
|
Данные о здоровье чувствительнее токенов. Система MUST NOT писать значения
|
||||||
точек и тела доставок в записи лога уровня выше `DEBUG`.
|
точек, содержимое сущностей и тела доставок в записи лога уровня выше `DEBUG`.
|
||||||
|
|
||||||
|
Содержимое сущности здесь не менее чувствительно, чем значение точки, а местами
|
||||||
|
более: маршрут тренировки — это геотрек до дома, а `labels` и `associations`
|
||||||
|
записи состояния разума — эмоциональные метки. Разрешены **координаты**:
|
||||||
|
идентификатор и род сущности, метка времени, идентификатор доставки — они
|
||||||
|
описывают, что случилось, а не что измерено.
|
||||||
|
|
||||||
Непокрытые секции называются в логе **именами ключей**: имя секции — это форма
|
Непокрытые секции называются в логе **именами ключей**: имя секции — это форма
|
||||||
пакета, а не измерение. Содержимое секции в лог не попадает ни при каком уровне
|
пакета, а не измерение. Содержимое секции в лог не попадает ни при каком уровне
|
||||||
выше `DEBUG`. Имена идут структурным атрибутом, а не склейкой в текст сообщения:
|
выше `DEBUG`. Имена идут структурным атрибутом, а не склейкой в текст сообщения:
|
||||||
кодировщик экранирует управляющие символы, и имя из чужого тела не разрывает
|
кодировщик экранирует управляющие символы, и имя из чужого тела не разрывает
|
||||||
построчный разбор логов.
|
построчный разбор логов. То же относится к идентификатору сущности: он приходит
|
||||||
|
из чужого тела и ограничен по длине при разборе.
|
||||||
|
|
||||||
Частичный разбор уровня записи не повышает: `partial` — установившееся состояние
|
Частичный разбор уровня записи не повышает: `partial` — установившееся состояние
|
||||||
половины потока (48 доставок из 99), и постоянный `WARN` обесценил бы уровень.
|
половины потока (53 доставки из 118), и постоянный `WARN` обесценил бы уровень.
|
||||||
Повышает уровень другое — срабатывание границ списка: тело с сотнями секций или
|
Повышает уровень другое — срабатывание границ списка: тело с сотнями секций или
|
||||||
с именем длиннее предела на HAE не похоже вовсе.
|
с именем длиннее предела на HAE не похоже вовсе.
|
||||||
|
|
||||||
#### Scenario: Разбор доставки логируется без значений
|
#### Scenario: Разбор доставки логируется без значений
|
||||||
|
|
||||||
- **WHEN** доставка разобрана
|
- **WHEN** доставка разобрана
|
||||||
- **THEN** запись лога содержит счётчики (метрик, точек, объектов) и
|
- **THEN** запись лога содержит счётчики (метрик, точек, объектов, сущностей) и
|
||||||
идентификатор доставки
|
идентификатор доставки
|
||||||
- **AND** не содержит ни значений точек, ни имён устройств
|
- **AND** не содержит ни значений точек, ни имён устройств
|
||||||
|
|
||||||
|
#### Scenario: Удержанная обеднённая версия логируется координатами
|
||||||
|
|
||||||
|
- **WHEN** приехавшая версия сущности отклонена как теряющая содержание
|
||||||
|
- **THEN** запись `WARN` содержит идентификатор и род сущности
|
||||||
|
- **AND** не содержит ни точек маршрута, ни того, какие поля потерялись
|
||||||
|
|
||||||
#### Scenario: Непокрытые секции названы именами ключей
|
#### Scenario: Непокрытые секции названы именами ключей
|
||||||
|
|
||||||
- **WHEN** доставка содержит непокрытую секцию
|
- **WHEN** доставка содержит непокрытую секцию
|
||||||
@@ -427,3 +440,271 @@ Apple его нет (находка 46). Поэтому список MUST сох
|
|||||||
- **THEN** `parse_status` становится `parsed`
|
- **THEN** `parse_status` становится `parsed`
|
||||||
- **AND** сохранённый список непокрытых ключей пуст
|
- **AND** сохранённый список непокрытых ключей пуст
|
||||||
|
|
||||||
|
### Requirement: Хранение сущностей с собственным идентификатором
|
||||||
|
|
||||||
|
Система SHALL хранить тренировки и записи секций с собственным `id` **не**
|
||||||
|
часовыми объектами, а по одной строке на сущность: у них есть естественный
|
||||||
|
ключ, они редки (за двое суток потока — две тренировки и две записи состояния
|
||||||
|
разума), и группировать их по часам незачем.
|
||||||
|
|
||||||
|
Единиц хранения две:
|
||||||
|
|
||||||
|
```
|
||||||
|
тренировка ключ id
|
||||||
|
заголовок колонками: имя, начало, конец, офсет зоны, длительность
|
||||||
|
запись ключ род секции + id
|
||||||
|
заголовок колонками: род, метка времени, офсет зоны
|
||||||
|
```
|
||||||
|
|
||||||
|
Сущность SHALL нести **провенанс** — идентификатор доставки, чья версия лежит
|
||||||
|
сейчас, и метку приёма этой доставки. Он нужен не отчётности: по нему
|
||||||
|
разрешается тай-брейк между версиями равной полноты (см. «Замена версии
|
||||||
|
сущности…»), и без него `WARN` об удержанной обеднённой версии не связать с
|
||||||
|
телом в архиве.
|
||||||
|
|
||||||
|
Длительность тренировки SHALL допускать отсутствие значения, отличимое от нуля:
|
||||||
|
ноль — законная длительность, и потребитель, сложивший столбец, иначе не отличил
|
||||||
|
бы «источник не прислал» от «измерено ноль».
|
||||||
|
|
||||||
|
Ключ записи SHALL быть парой `род + id`, а не одним `id`. Собственный `id`
|
||||||
|
наблюдался живьём только у `stateOfMind`, где он UUID HealthKit; форма
|
||||||
|
идентификатора остальных пяти секций не наблюдалась никем, и короткий
|
||||||
|
несквозной `id` в двух разных секциях затёр бы одну запись другой молча. Пара
|
||||||
|
стоит ноль: запросы к записям всегда идут с родом.
|
||||||
|
|
||||||
|
Содержимое сущности SHALL храниться **дословно** — теми же байтами, какими
|
||||||
|
пришло, включая маршрут, внутренние ряды и сводки. Заголовок колонками
|
||||||
|
существует ради выборки по времени и не является разбором содержимого: любая
|
||||||
|
следующая колонка была бы решением за Apple о том, что в тренировке главное.
|
||||||
|
|
||||||
|
Ряд пульса внутри тренировки MUST лежать в её содержимом, а не в объектах
|
||||||
|
метрики `heart_rate`: это разные таблицы, и смешение задвоило бы ряд.
|
||||||
|
|
||||||
|
Сущности доставки SHALL записываться **той же транзакцией**, что и её точки.
|
||||||
|
Доставка — единица свёртки; частичное состояние ломает инвариант «состояние
|
||||||
|
пересобираемо», а наблюдение «секции не смешиваются в одной доставке» собрано
|
||||||
|
за двое суток и основанием для второй транзакции не является.
|
||||||
|
|
||||||
|
Система SHALL хранить рядом с сущностью хеш её канонического содержимого и
|
||||||
|
пропускать запись, если хеш не изменился. Тренировка переприсылается каждой
|
||||||
|
доставкой автоматизации, пока не доедет маршрут: на живом архиве 44 доставленные
|
||||||
|
копии дают три различных содержимых.
|
||||||
|
|
||||||
|
Сравнение SHALL начинаться с хеша, читаемого **без** содержимого сохранённой
|
||||||
|
сущности: маршрут доходит до мегабайта, разжимать и канонизировать его на каждой
|
||||||
|
из 44 копий не за чем. Хеш приехавшей сущности SHALL считаться один раз на
|
||||||
|
доставку, а не на каждой попытке повтора транзакции при занятости базы:
|
||||||
|
канонизация материализует значение целиком, и повтор умножал бы пик кучи.
|
||||||
|
|
||||||
|
#### Scenario: Тренировка хранится одной строкой с маршрутом
|
||||||
|
|
||||||
|
- **WHEN** приезжает тренировка с маршрутом
|
||||||
|
- **THEN** она хранится одной строкой, адресуемой своим `id`
|
||||||
|
- **AND** маршрут и внутренние ряды лежат в её содержимом дословно
|
||||||
|
|
||||||
|
#### Scenario: Ряд пульса тренировки не попадает в метрику
|
||||||
|
|
||||||
|
- **WHEN** тренировка несёт `heartRateData`
|
||||||
|
- **THEN** объектов метрики `heart_rate` эта доставка не создаёт
|
||||||
|
|
||||||
|
#### Scenario: Записи разных родов с одинаковым id не сталкиваются
|
||||||
|
|
||||||
|
- **WHEN** две записи разных родов приезжают с одним и тем же `id`
|
||||||
|
- **THEN** в хранилище лежат обе
|
||||||
|
|
||||||
|
#### Scenario: Повторная присылка той же тренировки не пишет в базу
|
||||||
|
|
||||||
|
- **WHEN** приезжает тренировка, содержимое которой совпадает с сохранённым
|
||||||
|
- **THEN** хеш совпадает и запись не выполняется
|
||||||
|
|
||||||
|
#### Scenario: Отказ посреди доставки не оставляет части сущностей
|
||||||
|
|
||||||
|
- **WHEN** свёртка доставки прерывается на середине
|
||||||
|
- **THEN** не записывается ни одна сущность этой доставки
|
||||||
|
|
||||||
|
### Requirement: Замена версии сущности не теряет содержания
|
||||||
|
|
||||||
|
Сущность с собственным `id` SHALL замещаться **целиком**, а не сливаться по
|
||||||
|
полям: она приезжает повторно, пока источник её досчитывает. Замер на живом
|
||||||
|
архиве: одна тренировка приехала 26 раз в трёх различных содержимых — сперва
|
||||||
|
добавились `stepCadence` и `stepCount` вместе с изменившимся рядом
|
||||||
|
`activeEnergy`, затем при том же наборе полей досчитались `totalEnergy` и
|
||||||
|
`basalEnergy`.
|
||||||
|
|
||||||
|
Замещение MUST быть условным: приехавшая версия побеждает, **если не теряет
|
||||||
|
содержания** сохранённой. Порядок разбора:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. хеш канонического содержимого совпал → записи нет
|
||||||
|
2. содержание приехавшей покрывает сохранённую
|
||||||
|
и сверх того → приехавшая замещает целиком
|
||||||
|
3. приехавшая теряет содержание сохранённой → остаётся сохранённая,
|
||||||
|
счётчик + WARN
|
||||||
|
4. содержание сравнимо, наборы равны → версия из более поздней
|
||||||
|
доставки журнала
|
||||||
|
5. наборы несравнимы → остаётся сохранённая,
|
||||||
|
счётчик + WARN
|
||||||
|
```
|
||||||
|
|
||||||
|
**Содержание сравнивается множеством ключей с непустым значением — и только им.**
|
||||||
|
Сравнение полноты, принятое для точек, здесь неприменимо: оно гасит отношение
|
||||||
|
включения, когда значения общих содержательных ключей разошлись, а у сущности
|
||||||
|
они расходятся **всегда** — источник её досчитывает. Проверено: сохранённая
|
||||||
|
тренировка с маршрутом против приехавшей без маршрута даёт «надмножество» при
|
||||||
|
неизменных значениях и «равенство» при изменившихся, то есть на живых данных
|
||||||
|
защита не сработала бы вовсе, а тест на фикстуре с неизменёнными значениями
|
||||||
|
остался бы зелёным. Условия «значения общих ключей совпали» здесь быть MUST NOT.
|
||||||
|
|
||||||
|
Дополнительно к множеству ключей SHALL сравниваться **длина верхнеуровневых
|
||||||
|
массивов**: усечённый маршрут (три точки вместо 593) ключа не теряет, а теряет
|
||||||
|
95% содержимого тренировки. Досчёт ряды удлиняет, поэтому укорачивание —
|
||||||
|
законный признак «приехало меньше». Предел правила называется вслух: сокращение
|
||||||
|
**внутри** элемента ряда (точка маршрута без `altitude`) не ловится ничем, кроме
|
||||||
|
сверки с телом в архиве.
|
||||||
|
|
||||||
|
Единственная причина повторной присылки — доезжающий маршрут, то есть рост:
|
||||||
|
обратного за 44 доставленные копии не случилось ни разу. Но восстановление
|
||||||
|
требует пересборки всего журнала, поэтому событие делается наблюдаемым, а не
|
||||||
|
необратимым.
|
||||||
|
|
||||||
|
**Тай-брейк при равных наборах — позиция доставки в журнале `(received_at, id)`,
|
||||||
|
а не порядок свёртки.** «Побеждает приехавшая» было бы функцией порядка
|
||||||
|
свёртки, а он порядку журнала не равен: воркер сворачивает в порядке журнала
|
||||||
|
только среди видимых ему доставок и абсолютного порядка при конкурентных
|
||||||
|
приёмах не обещает. Доставка с более ранней меткой, свёрнутая позже, вернула бы
|
||||||
|
витрину к недосчитанной версии, и пересборка разошлась бы с живым приёмом молча,
|
||||||
|
в содержимом тренировки. Позиция журнала снимает это: исход зависит от журнала,
|
||||||
|
а не от того, кто раньше добрался до базы.
|
||||||
|
|
||||||
|
Отличие от точки здесь содержательное: у точки на одних координатах законно
|
||||||
|
встречаются два разных измерения, и предпочитать позднее нет оснований — там
|
||||||
|
исход решает порядок канонических форм. У сущности `id` — идентичность одного
|
||||||
|
объекта HealthKit, и вторая версия есть тот же объект, пересчитанный источником;
|
||||||
|
тай-брейк по канонической форме заморозил бы тренировку на произвольной из
|
||||||
|
версий навсегда, вместе с недосчитанной энергией.
|
||||||
|
|
||||||
|
Две версии одного ключа **внутри одной доставки** позициями не различаются и
|
||||||
|
SHALL разрешаться минимумом канонической формы — включая случай несравнимых
|
||||||
|
наборов. Внутри доставки «сохранённой» версии не существует, есть только
|
||||||
|
порядок элементов в JSON-массиве, а он нестабилен: правило «остаётся первая
|
||||||
|
встреченная» сделало бы исход функцией порядка на проводе. Сворачиваться между
|
||||||
|
собой такие версии SHALL до сравнения с сохранённой, а факт «в одном теле
|
||||||
|
приехали две версии одного ключа с разным содержанием» SHALL считаться
|
||||||
|
**симметрично**: счётчик, зависящий от порядка элементов, наблюдал бы событие
|
||||||
|
через раз.
|
||||||
|
|
||||||
|
Поля версий MUST NOT объединяться: несравнимые наборы (приехавшая принесла
|
||||||
|
новые ключи и потеряла старые) разрешаются в пользу сохранённой и считаются
|
||||||
|
тем же счётчиком. Объединение отвергнуто там же и по той же причине, что для
|
||||||
|
точек: на живом потоке событие не наступало, и вместо реализации заведено
|
||||||
|
наблюдение.
|
||||||
|
|
||||||
|
Исход SHALL быть функцией журнала в его порядке. Остаточный предел называется
|
||||||
|
вслух: слияние попарное — сохранённая против приехавшей, — поэтому при
|
||||||
|
несравнимых наборах (пункт 5) исход зависит от порядка проигрывания. Тот же
|
||||||
|
предел есть у часового объекта, где хранится победитель прошлых слияний, а не
|
||||||
|
все кандидаты истории; пункты 2–4 от порядка свёртки не зависят, а пункт 5
|
||||||
|
сопровождается счётчиком и `WARN`.
|
||||||
|
|
||||||
|
#### Scenario: Доехавший маршрут замещает тренировку без маршрута
|
||||||
|
|
||||||
|
- **WHEN** та же тренировка приезжает повторно, добавив `route`
|
||||||
|
- **THEN** в хранилище лежит версия с маршрутом
|
||||||
|
|
||||||
|
#### Scenario: Досчитанные значения при том же наборе полей побеждают
|
||||||
|
|
||||||
|
- **WHEN** та же тренировка приезжает повторно с тем же набором полей и
|
||||||
|
изменившимися значениями, доставкой с более поздней позицией журнала
|
||||||
|
- **THEN** в хранилище лежит приехавшая версия
|
||||||
|
|
||||||
|
#### Scenario: Версия из более ранней доставки не откатывает витрину
|
||||||
|
|
||||||
|
- **WHEN** две доставки несут одну тренировку с равными наборами полей, и
|
||||||
|
свёрнута сперва более поздняя по журналу, затем более ранняя
|
||||||
|
- **THEN** в хранилище лежит версия из более поздней доставки
|
||||||
|
- **AND** тот же исход даёт свёртка в обратном порядке
|
||||||
|
|
||||||
|
#### Scenario: Обеднённая версия сохранённую не затирает
|
||||||
|
|
||||||
|
- **WHEN** та же тренировка приезжает повторно **без** `route`, который был у
|
||||||
|
сохранённой, **и** с изменившимися значениями общих полей
|
||||||
|
- **THEN** в хранилище остаётся сохранённая версия
|
||||||
|
- **AND** факт учитывается счётчиком и записью `WARN` с идентификатором
|
||||||
|
тренировки
|
||||||
|
|
||||||
|
#### Scenario: Усечённый маршрут сохранённый не затирает
|
||||||
|
|
||||||
|
- **WHEN** та же тренировка приезжает повторно с тем же набором полей, но
|
||||||
|
`route` короче сохранённого
|
||||||
|
- **THEN** в хранилище остаётся сохранённая версия
|
||||||
|
- **AND** факт учитывается тем же счётчиком
|
||||||
|
|
||||||
|
#### Scenario: Две версии одной сущности в одном теле
|
||||||
|
|
||||||
|
- **WHEN** тело содержит два элемента секции с одним `id`
|
||||||
|
- **THEN** исход не зависит от их порядка в массиве
|
||||||
|
- **AND** счётчик различающихся версий тоже не зависит от их порядка
|
||||||
|
|
||||||
|
#### Scenario: Составной ключ не даёт коллизии отпечатка
|
||||||
|
|
||||||
|
- **WHEN** две витрины различаются только тем, где проходит граница между родом
|
||||||
|
и идентификатором записи
|
||||||
|
- **THEN** отпечатки не совпадают
|
||||||
|
|
||||||
|
#### Scenario: Несравнимые наборы полей не объединяются
|
||||||
|
|
||||||
|
- **WHEN** приехавшая версия несёт содержательный ключ, которого нет у
|
||||||
|
сохранённой, и теряет содержательный ключ, который у сохранённой есть
|
||||||
|
- **THEN** в хранилище остаётся сохранённая версия
|
||||||
|
- **AND** факт учитывается тем же счётчиком
|
||||||
|
|
||||||
|
#### Scenario: Повторная свёртка того же журнала состояния не меняет
|
||||||
|
|
||||||
|
- **WHEN** те же доставки сворачиваются повторно в том же порядке
|
||||||
|
- **THEN** содержимое сущностей не меняется
|
||||||
|
|
||||||
|
### Requirement: Отпечаток витрины покрывает все её сущности
|
||||||
|
|
||||||
|
Отпечаток витрины SHALL включать тренировки и записи наравне с часовыми
|
||||||
|
объектами: он единственный оракул сходимости пересборки, и отпечаток одних
|
||||||
|
объектов давал бы «состояние сошлось» при разъехавшихся тренировках — то есть
|
||||||
|
ломался бы молча ровно тем изменением, которое добавляет данные.
|
||||||
|
|
||||||
|
В отпечаток идут координаты сущности и хеш её содержимого. Значений он
|
||||||
|
раскрывать MUST NOT — как и для точек.
|
||||||
|
|
||||||
|
Порядок обхода SHALL быть детерминированным и заданным запросом, а не порядком
|
||||||
|
строк в файле базы. Строки разных разделов SHALL различаться константным
|
||||||
|
признаком раздела: без него строка одного раздела может совпасть со строкой
|
||||||
|
другого, и два разных состояния дали бы один отпечаток. По той же причине
|
||||||
|
составной ключ SHALL идти в отпечаток **отдельными полями с собственными
|
||||||
|
длинами**, а не склейкой: склейка выполняется до взятия длины, и пара
|
||||||
|
(`a`, `b/c`) даёт ту же строку, что (`a/b`, `c`).
|
||||||
|
|
||||||
|
Границу оракула стоит назвать вслух: в отпечаток идут координаты и хеш
|
||||||
|
содержимого, а **колонки заголовка** сущности (имя, конец интервала, офсет,
|
||||||
|
длительность) — нет. Они производны от содержимого, поэтому их расхождение
|
||||||
|
означает изменившийся код извлечения заголовка, а не разъехавшееся состояние; но
|
||||||
|
«отпечатки совпали» не является утверждением о них.
|
||||||
|
|
||||||
|
Все разделы SHALL читаться **одним снимком** базы. Отпечаток рабочей витрины
|
||||||
|
снимается под живым приёмом, и запросы вне общей транзакции чтения дали бы смесь
|
||||||
|
«объекты до» и «тренировки после» — то есть ложное расхождение у единственного
|
||||||
|
оракула сходимости.
|
||||||
|
|
||||||
|
#### Scenario: Расхождение тренировок видно в отпечатке
|
||||||
|
|
||||||
|
- **WHEN** две витрины совпадают по часовым объектам, но содержимое одной
|
||||||
|
тренировки различается
|
||||||
|
- **THEN** отпечатки не совпадают
|
||||||
|
|
||||||
|
#### Scenario: Отпечаток одинаков при одинаковом содержимом
|
||||||
|
|
||||||
|
- **WHEN** та же витрина собрана повторно из того же журнала
|
||||||
|
- **THEN** отпечаток совпадает
|
||||||
|
|
||||||
|
#### Scenario: Запись во время снятия отпечатка не смешивает разделы
|
||||||
|
|
||||||
|
- **WHEN** отпечаток снимается, а параллельно коммитится свёртка
|
||||||
|
- **THEN** отпечаток отражает одно состояние базы, а не смесь снимков
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user