feat: разбор и хранение тренировок и состояния разума
- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`; миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки с этими ключами - сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА — «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина расходилась бы с пересборкой молча - отпечаток витрины покрывает тренировки и записи и снимается одним снимком базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
This commit is contained in:
+239
-23
@@ -159,9 +159,29 @@ Read API «самый мелкий слой, покрывающий диапаз
|
||||
|
||||
### Requirement: Разбор форматов времени
|
||||
|
||||
Система SHALL разбирать метку точки формата `2026-07-31 21:03:51 +0300` и
|
||||
приводить её к UTC, сохраняя офсет исходной зоны. В секции `data.metrics`
|
||||
других форматов меток не встречается.
|
||||
Система SHALL разбирать метку формата `2026-07-31 21:03:51 +0300` и приводить
|
||||
её к UTC, сохраняя офсет исходной зоны. В секции `data.metrics` других форматов
|
||||
меток не встречается.
|
||||
|
||||
Система SHALL разбирать вторым форматом RFC 3339 в UTC
|
||||
(`2026-07-31T18:03:51Z`): им приходят метки секции `data.stateOfMind`, тогда
|
||||
как тренировки и метрики шлют первый формат. Оба формата SHALL приниматься **у
|
||||
любой** метки сущности, а не приписываться секции жёстко: формы однозначны и не
|
||||
пересекаются, а HAE выравнивает секции между собой по ходу своих обновлений —
|
||||
`stateOfMind` уже шлёт стабильные коды HealthKit там, где старые секции шлют
|
||||
переводы. Приписанный секции формат ломался бы молча в день такого выравнивания.
|
||||
|
||||
Метка RFC 3339 в UTC даёт офсет `0`, и это MUST означать «источник прислал
|
||||
UTC», а не «человек находился в нулевой зоне»: местной зоны у секции
|
||||
`stateOfMind` в потоке нет вовсе.
|
||||
|
||||
Метка **точки** при этом остаётся строгой — один формат, — и асимметрия
|
||||
намеренная. По метке точки выводится слой, причём по метке в **исходной зоне**;
|
||||
терпимость к RFC 3339 означала бы, что метка в UTC тихо портит выравнивание и
|
||||
часовая выгрузка складывается с минутной (наблюдалось: удвоение суммы за час).
|
||||
У сущности слоя нет, и терять на строгости нечего, а у точки строгий парсер
|
||||
отдаёт непонятую метку в счётчик пропусков — тело остаётся в архиве, и
|
||||
пересборка вернёт его, когда формат станет известен.
|
||||
|
||||
Unix-эпоха дробным числом (`1785446196.4132624`) встречается **внутри**
|
||||
`heartbeatSeries` и меткой точки не является. Система MUST NOT преобразовывать
|
||||
@@ -169,16 +189,24 @@ Unix-эпоха дробным числом (`1785446196.4132624`) встреч
|
||||
и обратно не гарантирует дословности, а серия составляет 93% объёма метрики
|
||||
`heart_rate_variability`.
|
||||
|
||||
RFC 3339 (`2026-07-31T18:03:51Z`) в этой дельте не нормируется: он встречается
|
||||
только в `data.stateOfMind`, которая выведена из scope. Требование к нему
|
||||
появится вместе с задачей про секции с собственными `id` — вместе с данными,
|
||||
на которых его можно проверить.
|
||||
Время внутри маршрута тренировки (`route[].timestamp`) меткой сущности тоже не
|
||||
является и MUST проходить дословно, не разбираясь.
|
||||
|
||||
#### Scenario: Локальное время со смещением
|
||||
|
||||
- **WHEN** метка имеет вид `2026-07-31 21:03:51 +0300`
|
||||
- **THEN** точка получает время в UTC и офсет `+10800` секунд
|
||||
|
||||
#### Scenario: RFC 3339 в UTC
|
||||
|
||||
- **WHEN** метка сущности имеет вид `2026-07-31T18:03:51Z`
|
||||
- **THEN** сущность получает время в UTC и офсет `0`
|
||||
|
||||
#### Scenario: Тренировка со временем в формате метрик
|
||||
|
||||
- **WHEN** тренировка несёт `start` вида `2026-08-01 10:04:31 +0300`
|
||||
- **THEN** сущность получает время в UTC и офсет `+10800` секунд
|
||||
|
||||
#### Scenario: Время внутри серии ударов
|
||||
|
||||
- **WHEN** точка метрики `heart_rate_variability` содержит `heartbeatSeries`
|
||||
@@ -186,6 +214,12 @@ RFC 3339 (`2026-07-31T18:03:51Z`) в этой дельте не нормируе
|
||||
- **AND** серия не разворачивается в отдельные точки
|
||||
- **AND** эпоха внутри серии не разбирается и не преобразуется
|
||||
|
||||
#### Scenario: Время внутри маршрута не разбирается
|
||||
|
||||
- **WHEN** тренировка содержит `route` с полем `timestamp` у каждой точки
|
||||
- **THEN** точки маршрута сохраняются исходными байтами
|
||||
- **AND** их метки не разбираются и не преобразуются
|
||||
|
||||
### Requirement: Разделение схем под одним именем метрики
|
||||
|
||||
Система SHALL разводить на разные имена метрики те схемы, которые Health Auto
|
||||
@@ -270,32 +304,38 @@ Export шлёт под одним именем, чтобы одно имя оз
|
||||
MUST NOT удерживаться после того, как разбор прошёл мимо неё: тела доходят до
|
||||
42 МиБ, и удержание кучи здесь — часть контракта, а не деталь реализации.
|
||||
|
||||
Покрытым сегодня является ровно один ключ — `metrics`. Разбор и перечисление
|
||||
MUST ходить по одному объявленному множеству покрытых имён: состояние «секция
|
||||
разбирается, но числится непокрытой» невыразимо по построению.
|
||||
Покрытых ключей сегодня три — `metrics`, `workouts` и `stateOfMind`. Разбор и
|
||||
перечисление MUST ходить по одному объявленному множеству покрытых имён:
|
||||
состояние «секция разбирается, но числится непокрытой» невыразимо по построению.
|
||||
|
||||
Непокрытым ключ считается независимо от того, что лежит внутри: содержимое не
|
||||
интерпретируется, поэтому и о пустоте секции разбор честно ничего не знает.
|
||||
Измерено на живом архиве — пустых секций HAE не присылает ни разу (99 доставок).
|
||||
Измерено на живом архиве — пустых секций HAE не присылает ни разу (118 доставок).
|
||||
|
||||
Список SHALL быть каноничен: имена отсортированы, повторов нет. Порядок ключей в
|
||||
JSON от HAE нестабилен, а значение уезжает в базу и сравнивается между
|
||||
доставками.
|
||||
|
||||
Отсутствие непокрытых ключей и отсутствие секции `metrics` — разные события, и
|
||||
оба нормальны: половина потока состоит из доставок без метрик вовсе (48 из 99).
|
||||
оба нормальны: половина потока состоит из доставок без метрик вовсе (53 из 118).
|
||||
|
||||
#### Scenario: Незнакомая секция попадает в список непокрытых
|
||||
|
||||
- **WHEN** тело содержит `data.workouts` наряду с `data.metrics`
|
||||
- **THEN** разбор возвращает `workouts` в списке непокрытых ключей
|
||||
- **WHEN** тело содержит `data.ecg` наряду с `data.metrics`
|
||||
- **THEN** разбор возвращает `ecg` в списке непокрытых ключей
|
||||
- **AND** точки секции `metrics` разбираются как обычно
|
||||
|
||||
#### Scenario: Доставка без метрик разбирается и не теряется
|
||||
#### Scenario: Доставка из одних тренировок непокрытых ключей не даёт
|
||||
|
||||
- **WHEN** тело содержит только `data.workouts`
|
||||
- **THEN** разбор завершается без ошибки, точек нет, тренировки разобраны
|
||||
- **AND** список непокрытых ключей пуст
|
||||
|
||||
#### Scenario: Доставка из одного состояния разума непокрытых ключей не даёт
|
||||
|
||||
- **WHEN** тело содержит только `data.stateOfMind`
|
||||
- **THEN** разбор завершается без ошибки, точек нет
|
||||
- **AND** `stateOfMind` возвращается в списке непокрытых ключей
|
||||
- **THEN** разбор завершается без ошибки, записи разобраны
|
||||
- **AND** список непокрытых ключей пуст
|
||||
|
||||
#### Scenario: Доставка из одних метрик непокрытых ключей не даёт
|
||||
|
||||
@@ -345,15 +385,26 @@ JSON от HAE нестабилен, а значение уезжает в баз
|
||||
### Requirement: Отказ разбора остаётся всё или ничего
|
||||
|
||||
Разбор SHALL оставаться операцией «всё или ничего»: ошибка, встреченная
|
||||
**после** того, как секция `metrics` уже разобрана (обрезанное тело, мусор в
|
||||
следующем члене), MUST NOT оставлять точки в результате — доставка считается
|
||||
неразобранной целиком.
|
||||
**после** того, как покрытая секция уже разобрана (обрезанное тело, мусор в
|
||||
следующем члене), MUST NOT оставлять в результате ни точек, ни сущностей —
|
||||
доставка считается неразобранной целиком.
|
||||
|
||||
Иначе часть точек оказалась бы в витрине под статусом, по которому доставку
|
||||
Иначе часть данных оказалась бы в витрине под статусом, по которому доставку
|
||||
никто не подберёт, и свёртка перестала бы быть детерминированной по журналу.
|
||||
|
||||
Повтор ключа `metrics` в одном объекте `data` SHALL давать объединение секций, а
|
||||
не победу последней: молча терять точки нельзя.
|
||||
Правило SHALL распространяться и на невыводимый слой: доставка, у которой есть
|
||||
метрики, но слой их не определяется, не сохраняет и своих сущностей, хотя слоя
|
||||
у сущности нет. Соблазн «сущности от слоя не зависят, запишем их» ломает то же
|
||||
«всё или ничего» — доставка получила бы `failed` при частично записанной
|
||||
витрине, и повторная свёртка перестала бы быть no-op. Цена названа вслух: если
|
||||
такая доставка когда-нибудь принесёт `stateOfMind`, его записи доедут не сразу,
|
||||
а пересборкой; тело при этом остаётся в архиве, и `failed` ретеншену трогать
|
||||
нельзя.
|
||||
|
||||
Повтор ключа покрытой секции в одном объекте `data` SHALL давать объединение
|
||||
секций, а не победу последней: молча терять данные нельзя. То же SHALL
|
||||
относиться к повтору самого члена `data` в теле — результаты **накапливаются**,
|
||||
включая список непокрытых ключей.
|
||||
|
||||
#### Scenario: Тело оборвано после секции метрик
|
||||
|
||||
@@ -361,8 +412,173 @@ JSON от HAE нестабилен, а значение уезжает в баз
|
||||
оборван
|
||||
- **THEN** разбор завершается ошибкой и точек не отдаёт
|
||||
|
||||
#### Scenario: Тело оборвано после секции тренировок
|
||||
|
||||
- **WHEN** тело содержит целую секцию `workouts`, а следующий член `data`
|
||||
оборван
|
||||
- **THEN** разбор завершается ошибкой и сущностей не отдаёт
|
||||
|
||||
#### Scenario: Невыводимый слой не сохраняет и сущностей
|
||||
|
||||
- **WHEN** доставка несёт метрики, слой которых не определяется, и вместе с
|
||||
ними секцию `stateOfMind`
|
||||
- **THEN** разбор завершается ошибкой, ни точек, ни записей не отдаёт
|
||||
- **AND** список непокрытых ключей переживает отказ
|
||||
|
||||
#### Scenario: Секция метрик встречается дважды
|
||||
|
||||
- **WHEN** объект `data` содержит два ключа `metrics`
|
||||
- **THEN** точки обеих секций попадают в результат
|
||||
|
||||
#### Scenario: Секция тренировок встречается дважды
|
||||
|
||||
- **WHEN** объект `data` содержит два ключа `workouts`
|
||||
- **THEN** сущности обеих секций попадают в результат
|
||||
|
||||
#### Scenario: Член `data` встречается дважды
|
||||
|
||||
- **WHEN** тело содержит два члена `data`, из которых первый несёт непокрытую
|
||||
секцию, а второй — покрытую
|
||||
- **THEN** данные покрытой секции попадают в результат
|
||||
- **AND** имя непокрытой секции остаётся в списке непокрытых ключей
|
||||
|
||||
### Requirement: Разбор секций с собственными идентификаторами
|
||||
|
||||
Система SHALL разбирать секции тела, элементы которых несут собственный `id`, в
|
||||
**сущности**, а не в точки: у сущности нет ни слоя, ни координатного ключа
|
||||
`метрика + слой + начало + конец` — её адресует сам `id`.
|
||||
|
||||
Покрываются две такие секции: `data.workouts` и `data.stateOfMind`. Секции
|
||||
`ecg`, `symptoms`, `cycleTracking`, `medications` и `heartRateNotifications`
|
||||
покрытыми MUST NOT становиться: живой поток не приносил их ни разу (118
|
||||
доставок), их форма никем не наблюдалась, а полнота покрытия HealthKit ради
|
||||
полноты целью проекта не является. Они остаются в списке непокрытых, и доставка
|
||||
с ними остаётся `partial`.
|
||||
|
||||
Из тренировки разбор SHALL брать только то, по чему потом идёт выборка:
|
||||
идентификатор, имя, начало, конец, офсет исходной зоны и длительность. Всё
|
||||
остальное — включая маршрут, внутренние ряды (`heartRateData`,
|
||||
`activeEnergy`, `heartRateRecovery`) и сводки — MUST храниться содержимым
|
||||
сущности **дословно**, теми же байтами, какими пришло. Раскладывать структуру
|
||||
тренировки по колонкам значило бы решить за Apple, что в ней главное: сводки
|
||||
дублируют ряды (`distance` — это сумма `walkingAndRunningDistance`), а набор
|
||||
полей зависит от типа тренировки (у уличной есть `route`, `avgSpeed`,
|
||||
`flightsClimbed`, у домашней — `temperature`, `humidity`, `intensity`).
|
||||
|
||||
Длительность SHALL браться из тела, а не вычисляться из начала и конца: HAE
|
||||
шлёт `91.746` секунды при интервале в 91 секунду, и вычисленное значение молча
|
||||
разошлось бы с присланным. Отсутствие или нечисловое значение длительности
|
||||
сущность MUST NOT отбрасывать; такая длительность SHALL быть выражена
|
||||
отсутствием значения, а не нулём — ноль является законной длительностью, и
|
||||
потребитель не отличил бы «источник не прислал» от «измерено ноль».
|
||||
|
||||
Началом сущности SHALL быть `start`, при его отсутствии — `date`. Конец берётся
|
||||
из `end`; при отсутствии или неразбираемости конца он SHALL равняться началу, а
|
||||
истина остаётся в содержимом. Вырождение интервала здесь безопасно, в отличие от
|
||||
точки: ключ сущности — `id`, схлопывать координаты нечем. Офсет исходной зоны
|
||||
SHALL браться из начала: колонка одна, а тренировка через смену зоны дала бы
|
||||
два разных.
|
||||
|
||||
Из записи разбор SHALL брать идентификатор, род секции, метку времени и офсет;
|
||||
всё остальное хранится дословно. Род записи SHALL быть верхнеуровневым ключом
|
||||
секции HAE **дословно** (`stateOfMind`, не `state_of_mind`): инвариант «форма
|
||||
Apple не транслируется» относится и к именам секций.
|
||||
|
||||
Длина идентификатора SHALL быть ограничена, и сущность с более длинным `id`
|
||||
SHALL пропускаться тем же счётчиком, что и сущность без `id`. Идентификатор
|
||||
приходит из тела, которым отправитель управляет целиком, а уезжает и в ключ
|
||||
таблицы, и в записи лога; правило то же, что уже действует для имён непокрытых
|
||||
секций.
|
||||
|
||||
Ряд пульса **внутри** тренировки MUST NOT попадать в метрику `heart_rate`:
|
||||
это разные сущности хранилища. Пульс приезжает дважды — в общем потоке метрик и
|
||||
внутри тренировки, — и смешение задвоило бы ряд.
|
||||
|
||||
Сущность без `id` либо без разбираемой метки времени SHALL пропускаться со
|
||||
счётчиком, не роняя разбор остального: тело остаётся в архиве, и доставку
|
||||
подберёт пересборка, когда разбор научится её понимать.
|
||||
|
||||
Отсутствие покрытой секции в теле ошибкой быть MUST NOT: доставки из одних
|
||||
метрик — большинство потока.
|
||||
|
||||
#### Scenario: Тренировка разбирается вместе с маршрутом
|
||||
|
||||
- **WHEN** тело содержит `data.workouts` с тренировкой, несущей `route`
|
||||
- **THEN** разбор отдаёт сущность с идентификатором, именем, началом, концом,
|
||||
офсетом и длительностью
|
||||
- **AND** её содержимое несёт маршрут и внутренние ряды исходными байтами
|
||||
|
||||
#### Scenario: Ряд пульса тренировки не становится метрикой
|
||||
|
||||
- **WHEN** тренировка содержит `heartRateData`
|
||||
- **THEN** точки этого ряда не попадают в точки метрик
|
||||
- **AND** остаются внутри содержимого сущности
|
||||
|
||||
#### Scenario: Запись состояния разума разбирается
|
||||
|
||||
- **WHEN** тело содержит `data.stateOfMind` с элементом, несущим `id` и `start`
|
||||
- **THEN** разбор отдаёт запись с родом `stateOfMind`, идентификатором, меткой
|
||||
времени и содержимым исходными байтами
|
||||
|
||||
#### Scenario: Сущность без идентификатора пропускается
|
||||
|
||||
- **WHEN** элемент покрытой секции не несёт `id` либо `id` пуст
|
||||
- **THEN** сущность в результат разбора не попадает
|
||||
- **AND** факт учитывается счётчиком, а разбор остальных сущностей продолжается
|
||||
|
||||
#### Scenario: Элемент секции не является объектом
|
||||
|
||||
- **WHEN** элемент покрытой секции не разбирается как объект JSON
|
||||
- **THEN** сущность в результат разбора не попадает
|
||||
- **AND** факт учитывается **отдельным** счётчиком, а соседние сущности
|
||||
разбираются как обычно
|
||||
|
||||
Отдельным, а не общим с «нет `id`»: доставка, где не разобрался сам элемент, —
|
||||
это сменившаяся форма секции, а доставка без `id` — сменившаяся форма
|
||||
идентификатора. Ронять из-за такого элемента всю доставку нельзя тем более:
|
||||
`failed` фоновая свёртка не подбирает никогда, и вместе с одной кривой
|
||||
тренировкой в него уехали бы записи `stateOfMind` той же доставки.
|
||||
|
||||
#### Scenario: Сущность без разбираемой метки времени пропускается
|
||||
|
||||
- **WHEN** элемент покрытой секции несёт `id`, но его метка времени не
|
||||
разбирается ни одним из поддерживаемых форматов
|
||||
- **THEN** сущность в результат разбора не попадает
|
||||
- **AND** факт учитывается счётчиком
|
||||
|
||||
#### Scenario: Сущность со слишком длинным идентификатором пропускается
|
||||
|
||||
- **WHEN** элемент покрытой секции несёт `id` длиннее предела
|
||||
- **THEN** сущность в результат разбора не попадает
|
||||
- **AND** факт учитывается тем же счётчиком, что и отсутствие `id`
|
||||
|
||||
#### Scenario: Длительность берётся из тела, а не из интервала
|
||||
|
||||
- **WHEN** тренировка несёт `duration` равный `91.746` при интервале
|
||||
`start`/`end` в 91 секунду
|
||||
- **THEN** длительность сущности равна `91.746`
|
||||
|
||||
#### Scenario: Тренировка без длительности сохраняется без неё
|
||||
|
||||
- **WHEN** тренировка не несёт `duration` либо оно не является числом
|
||||
- **THEN** сущность сохраняется, а её длительность остаётся незаполненной
|
||||
- **AND** нулём она MUST NOT становиться
|
||||
|
||||
#### Scenario: Нечитаемый конец тренировки не отбрасывает её
|
||||
|
||||
- **WHEN** тренировка несёт `end`, который не разбирается
|
||||
- **THEN** конец сущности равен её началу
|
||||
- **AND** исходное значение остаётся в содержимом дословно
|
||||
|
||||
#### Scenario: Незнакомое поле тренировки переживает разбор
|
||||
|
||||
- **WHEN** тренировка несёт поле, которого разбор не знает
|
||||
- **THEN** оно сохраняется в содержимом сущности дословно
|
||||
- **AND** разбор не завершается ошибкой
|
||||
|
||||
#### Scenario: Непокрытая секция с собственными id остаётся непокрытой
|
||||
|
||||
- **WHEN** тело содержит `data.ecg`
|
||||
- **THEN** `ecg` попадает в список непокрытых ключей
|
||||
- **AND** сущностей из неё разбор не отдаёт
|
||||
|
||||
|
||||
@@ -309,6 +309,12 @@
|
||||
отпечатка** — рабочей витрины и пересобранной, — с прямым ответом, совпали они
|
||||
или нет.
|
||||
|
||||
Счётчики «до и после» SHALL покрывать **каждую единицу хранения витрины**:
|
||||
часовые объекты, тренировки и записи. Отпечаток отвечает «да/нет» за витрину
|
||||
целиком, поэтому единица, которой нет в счётчиках, делает расхождение
|
||||
безадресным: человек увидит «не совпало» при неизменившемся числе объектов и не
|
||||
отличит появление двадцати семи тренировок от пропажи двух.
|
||||
|
||||
Отказы SHALL считаться **по классам**: слой не выводится, содержимое не
|
||||
разбирается, работа отложена по обстоятельствам, всё прочее. Невыведенный слой
|
||||
есть в каждом журнале и штатен; общий счётчик отправлял бы человека искать
|
||||
@@ -342,8 +348,14 @@
|
||||
пересобранной витрины и число доставок после прогона не измеряются вовсе —
|
||||
печатать их сравнение значило бы выдать неизмеренное за измеренное, причём в
|
||||
единственном оракуле задачи. Ожидаемые классы расхождения (новые доставки за время прогона,
|
||||
непереносимый признак запечатанного часа, исправленный разбор) SHALL называться
|
||||
отдельно от самого факта расхождения.
|
||||
непереносимый признак запечатанного часа, исправленный разбор, **покрытая
|
||||
разбором новая секция**) SHALL называться отдельно от самого факта расхождения.
|
||||
|
||||
Класс «покрыта новая секция» назван потому, что первый прогон после такого
|
||||
изменения расходится **гарантированно** и штатно: витрина обзаводится единицами
|
||||
хранения, которых в рабочей базе нет по построению. Не назвав его, отчёт
|
||||
приучает человека игнорировать расхождение отпечатков — то есть обесценивает
|
||||
оракул ровно тогда, когда по нему принимается необратимое решение.
|
||||
|
||||
**Исход команды.** Расхождение отпечатков отказом быть MUST NOT: после
|
||||
исправления разбора оно ожидаемо и есть сам смысл пересборки. Отказ отдельной
|
||||
@@ -373,6 +385,12 @@ SHALL идти в поток ошибок, а не смешиваться с о
|
||||
- **AND** прямо называет, совпали они или нет
|
||||
- **AND** называет, изменилось ли число доставок в рабочей базе за время прогона
|
||||
|
||||
#### Scenario: Счётчики покрывают все единицы хранения
|
||||
|
||||
- **WHEN** пересборка завершилась
|
||||
- **THEN** отчёт печатает «до и после» отдельно для часовых объектов,
|
||||
тренировок и записей
|
||||
|
||||
#### Scenario: Расхождение отпечатков не является отказом
|
||||
|
||||
- **WHEN** отпечаток пересобранной витрины отличается от рабочей, и при этом
|
||||
|
||||
@@ -301,26 +301,39 @@ HTML-экранирования: `&`, `<` и `>` внутри точки обя
|
||||
### Requirement: Значения точек не попадают в логи
|
||||
|
||||
Данные о здоровье чувствительнее токенов. Система MUST NOT писать значения
|
||||
точек и тела доставок в записи лога уровня выше `DEBUG`.
|
||||
точек, содержимое сущностей и тела доставок в записи лога уровня выше `DEBUG`.
|
||||
|
||||
Содержимое сущности здесь не менее чувствительно, чем значение точки, а местами
|
||||
более: маршрут тренировки — это геотрек до дома, а `labels` и `associations`
|
||||
записи состояния разума — эмоциональные метки. Разрешены **координаты**:
|
||||
идентификатор и род сущности, метка времени, идентификатор доставки — они
|
||||
описывают, что случилось, а не что измерено.
|
||||
|
||||
Непокрытые секции называются в логе **именами ключей**: имя секции — это форма
|
||||
пакета, а не измерение. Содержимое секции в лог не попадает ни при каком уровне
|
||||
выше `DEBUG`. Имена идут структурным атрибутом, а не склейкой в текст сообщения:
|
||||
кодировщик экранирует управляющие символы, и имя из чужого тела не разрывает
|
||||
построчный разбор логов.
|
||||
построчный разбор логов. То же относится к идентификатору сущности: он приходит
|
||||
из чужого тела и ограничен по длине при разборе.
|
||||
|
||||
Частичный разбор уровня записи не повышает: `partial` — установившееся состояние
|
||||
половины потока (48 доставок из 99), и постоянный `WARN` обесценил бы уровень.
|
||||
половины потока (53 доставки из 118), и постоянный `WARN` обесценил бы уровень.
|
||||
Повышает уровень другое — срабатывание границ списка: тело с сотнями секций или
|
||||
с именем длиннее предела на HAE не похоже вовсе.
|
||||
|
||||
#### Scenario: Разбор доставки логируется без значений
|
||||
|
||||
- **WHEN** доставка разобрана
|
||||
- **THEN** запись лога содержит счётчики (метрик, точек, объектов) и
|
||||
- **THEN** запись лога содержит счётчики (метрик, точек, объектов, сущностей) и
|
||||
идентификатор доставки
|
||||
- **AND** не содержит ни значений точек, ни имён устройств
|
||||
|
||||
#### Scenario: Удержанная обеднённая версия логируется координатами
|
||||
|
||||
- **WHEN** приехавшая версия сущности отклонена как теряющая содержание
|
||||
- **THEN** запись `WARN` содержит идентификатор и род сущности
|
||||
- **AND** не содержит ни точек маршрута, ни того, какие поля потерялись
|
||||
|
||||
#### Scenario: Непокрытые секции названы именами ключей
|
||||
|
||||
- **WHEN** доставка содержит непокрытую секцию
|
||||
@@ -427,3 +440,271 @@ Apple его нет (находка 46). Поэтому список MUST сох
|
||||
- **THEN** `parse_status` становится `parsed`
|
||||
- **AND** сохранённый список непокрытых ключей пуст
|
||||
|
||||
### Requirement: Хранение сущностей с собственным идентификатором
|
||||
|
||||
Система SHALL хранить тренировки и записи секций с собственным `id` **не**
|
||||
часовыми объектами, а по одной строке на сущность: у них есть естественный
|
||||
ключ, они редки (за двое суток потока — две тренировки и две записи состояния
|
||||
разума), и группировать их по часам незачем.
|
||||
|
||||
Единиц хранения две:
|
||||
|
||||
```
|
||||
тренировка ключ id
|
||||
заголовок колонками: имя, начало, конец, офсет зоны, длительность
|
||||
запись ключ род секции + id
|
||||
заголовок колонками: род, метка времени, офсет зоны
|
||||
```
|
||||
|
||||
Сущность SHALL нести **провенанс** — идентификатор доставки, чья версия лежит
|
||||
сейчас, и метку приёма этой доставки. Он нужен не отчётности: по нему
|
||||
разрешается тай-брейк между версиями равной полноты (см. «Замена версии
|
||||
сущности…»), и без него `WARN` об удержанной обеднённой версии не связать с
|
||||
телом в архиве.
|
||||
|
||||
Длительность тренировки SHALL допускать отсутствие значения, отличимое от нуля:
|
||||
ноль — законная длительность, и потребитель, сложивший столбец, иначе не отличил
|
||||
бы «источник не прислал» от «измерено ноль».
|
||||
|
||||
Ключ записи SHALL быть парой `род + id`, а не одним `id`. Собственный `id`
|
||||
наблюдался живьём только у `stateOfMind`, где он UUID HealthKit; форма
|
||||
идентификатора остальных пяти секций не наблюдалась никем, и короткий
|
||||
несквозной `id` в двух разных секциях затёр бы одну запись другой молча. Пара
|
||||
стоит ноль: запросы к записям всегда идут с родом.
|
||||
|
||||
Содержимое сущности SHALL храниться **дословно** — теми же байтами, какими
|
||||
пришло, включая маршрут, внутренние ряды и сводки. Заголовок колонками
|
||||
существует ради выборки по времени и не является разбором содержимого: любая
|
||||
следующая колонка была бы решением за Apple о том, что в тренировке главное.
|
||||
|
||||
Ряд пульса внутри тренировки MUST лежать в её содержимом, а не в объектах
|
||||
метрики `heart_rate`: это разные таблицы, и смешение задвоило бы ряд.
|
||||
|
||||
Сущности доставки SHALL записываться **той же транзакцией**, что и её точки.
|
||||
Доставка — единица свёртки; частичное состояние ломает инвариант «состояние
|
||||
пересобираемо», а наблюдение «секции не смешиваются в одной доставке» собрано
|
||||
за двое суток и основанием для второй транзакции не является.
|
||||
|
||||
Система SHALL хранить рядом с сущностью хеш её канонического содержимого и
|
||||
пропускать запись, если хеш не изменился. Тренировка переприсылается каждой
|
||||
доставкой автоматизации, пока не доедет маршрут: на живом архиве 44 доставленные
|
||||
копии дают три различных содержимых.
|
||||
|
||||
Сравнение SHALL начинаться с хеша, читаемого **без** содержимого сохранённой
|
||||
сущности: маршрут доходит до мегабайта, разжимать и канонизировать его на каждой
|
||||
из 44 копий не за чем. Хеш приехавшей сущности SHALL считаться один раз на
|
||||
доставку, а не на каждой попытке повтора транзакции при занятости базы:
|
||||
канонизация материализует значение целиком, и повтор умножал бы пик кучи.
|
||||
|
||||
#### Scenario: Тренировка хранится одной строкой с маршрутом
|
||||
|
||||
- **WHEN** приезжает тренировка с маршрутом
|
||||
- **THEN** она хранится одной строкой, адресуемой своим `id`
|
||||
- **AND** маршрут и внутренние ряды лежат в её содержимом дословно
|
||||
|
||||
#### Scenario: Ряд пульса тренировки не попадает в метрику
|
||||
|
||||
- **WHEN** тренировка несёт `heartRateData`
|
||||
- **THEN** объектов метрики `heart_rate` эта доставка не создаёт
|
||||
|
||||
#### Scenario: Записи разных родов с одинаковым id не сталкиваются
|
||||
|
||||
- **WHEN** две записи разных родов приезжают с одним и тем же `id`
|
||||
- **THEN** в хранилище лежат обе
|
||||
|
||||
#### Scenario: Повторная присылка той же тренировки не пишет в базу
|
||||
|
||||
- **WHEN** приезжает тренировка, содержимое которой совпадает с сохранённым
|
||||
- **THEN** хеш совпадает и запись не выполняется
|
||||
|
||||
#### Scenario: Отказ посреди доставки не оставляет части сущностей
|
||||
|
||||
- **WHEN** свёртка доставки прерывается на середине
|
||||
- **THEN** не записывается ни одна сущность этой доставки
|
||||
|
||||
### Requirement: Замена версии сущности не теряет содержания
|
||||
|
||||
Сущность с собственным `id` SHALL замещаться **целиком**, а не сливаться по
|
||||
полям: она приезжает повторно, пока источник её досчитывает. Замер на живом
|
||||
архиве: одна тренировка приехала 26 раз в трёх различных содержимых — сперва
|
||||
добавились `stepCadence` и `stepCount` вместе с изменившимся рядом
|
||||
`activeEnergy`, затем при том же наборе полей досчитались `totalEnergy` и
|
||||
`basalEnergy`.
|
||||
|
||||
Замещение MUST быть условным: приехавшая версия побеждает, **если не теряет
|
||||
содержания** сохранённой. Порядок разбора:
|
||||
|
||||
```
|
||||
1. хеш канонического содержимого совпал → записи нет
|
||||
2. содержание приехавшей покрывает сохранённую
|
||||
и сверх того → приехавшая замещает целиком
|
||||
3. приехавшая теряет содержание сохранённой → остаётся сохранённая,
|
||||
счётчик + WARN
|
||||
4. содержание сравнимо, наборы равны → версия из более поздней
|
||||
доставки журнала
|
||||
5. наборы несравнимы → остаётся сохранённая,
|
||||
счётчик + WARN
|
||||
```
|
||||
|
||||
**Содержание сравнивается множеством ключей с непустым значением — и только им.**
|
||||
Сравнение полноты, принятое для точек, здесь неприменимо: оно гасит отношение
|
||||
включения, когда значения общих содержательных ключей разошлись, а у сущности
|
||||
они расходятся **всегда** — источник её досчитывает. Проверено: сохранённая
|
||||
тренировка с маршрутом против приехавшей без маршрута даёт «надмножество» при
|
||||
неизменных значениях и «равенство» при изменившихся, то есть на живых данных
|
||||
защита не сработала бы вовсе, а тест на фикстуре с неизменёнными значениями
|
||||
остался бы зелёным. Условия «значения общих ключей совпали» здесь быть MUST NOT.
|
||||
|
||||
Дополнительно к множеству ключей SHALL сравниваться **длина верхнеуровневых
|
||||
массивов**: усечённый маршрут (три точки вместо 593) ключа не теряет, а теряет
|
||||
95% содержимого тренировки. Досчёт ряды удлиняет, поэтому укорачивание —
|
||||
законный признак «приехало меньше». Предел правила называется вслух: сокращение
|
||||
**внутри** элемента ряда (точка маршрута без `altitude`) не ловится ничем, кроме
|
||||
сверки с телом в архиве.
|
||||
|
||||
Единственная причина повторной присылки — доезжающий маршрут, то есть рост:
|
||||
обратного за 44 доставленные копии не случилось ни разу. Но восстановление
|
||||
требует пересборки всего журнала, поэтому событие делается наблюдаемым, а не
|
||||
необратимым.
|
||||
|
||||
**Тай-брейк при равных наборах — позиция доставки в журнале `(received_at, id)`,
|
||||
а не порядок свёртки.** «Побеждает приехавшая» было бы функцией порядка
|
||||
свёртки, а он порядку журнала не равен: воркер сворачивает в порядке журнала
|
||||
только среди видимых ему доставок и абсолютного порядка при конкурентных
|
||||
приёмах не обещает. Доставка с более ранней меткой, свёрнутая позже, вернула бы
|
||||
витрину к недосчитанной версии, и пересборка разошлась бы с живым приёмом молча,
|
||||
в содержимом тренировки. Позиция журнала снимает это: исход зависит от журнала,
|
||||
а не от того, кто раньше добрался до базы.
|
||||
|
||||
Отличие от точки здесь содержательное: у точки на одних координатах законно
|
||||
встречаются два разных измерения, и предпочитать позднее нет оснований — там
|
||||
исход решает порядок канонических форм. У сущности `id` — идентичность одного
|
||||
объекта HealthKit, и вторая версия есть тот же объект, пересчитанный источником;
|
||||
тай-брейк по канонической форме заморозил бы тренировку на произвольной из
|
||||
версий навсегда, вместе с недосчитанной энергией.
|
||||
|
||||
Две версии одного ключа **внутри одной доставки** позициями не различаются и
|
||||
SHALL разрешаться минимумом канонической формы — включая случай несравнимых
|
||||
наборов. Внутри доставки «сохранённой» версии не существует, есть только
|
||||
порядок элементов в JSON-массиве, а он нестабилен: правило «остаётся первая
|
||||
встреченная» сделало бы исход функцией порядка на проводе. Сворачиваться между
|
||||
собой такие версии SHALL до сравнения с сохранённой, а факт «в одном теле
|
||||
приехали две версии одного ключа с разным содержанием» SHALL считаться
|
||||
**симметрично**: счётчик, зависящий от порядка элементов, наблюдал бы событие
|
||||
через раз.
|
||||
|
||||
Поля версий MUST NOT объединяться: несравнимые наборы (приехавшая принесла
|
||||
новые ключи и потеряла старые) разрешаются в пользу сохранённой и считаются
|
||||
тем же счётчиком. Объединение отвергнуто там же и по той же причине, что для
|
||||
точек: на живом потоке событие не наступало, и вместо реализации заведено
|
||||
наблюдение.
|
||||
|
||||
Исход SHALL быть функцией журнала в его порядке. Остаточный предел называется
|
||||
вслух: слияние попарное — сохранённая против приехавшей, — поэтому при
|
||||
несравнимых наборах (пункт 5) исход зависит от порядка проигрывания. Тот же
|
||||
предел есть у часового объекта, где хранится победитель прошлых слияний, а не
|
||||
все кандидаты истории; пункты 2–4 от порядка свёртки не зависят, а пункт 5
|
||||
сопровождается счётчиком и `WARN`.
|
||||
|
||||
#### Scenario: Доехавший маршрут замещает тренировку без маршрута
|
||||
|
||||
- **WHEN** та же тренировка приезжает повторно, добавив `route`
|
||||
- **THEN** в хранилище лежит версия с маршрутом
|
||||
|
||||
#### Scenario: Досчитанные значения при том же наборе полей побеждают
|
||||
|
||||
- **WHEN** та же тренировка приезжает повторно с тем же набором полей и
|
||||
изменившимися значениями, доставкой с более поздней позицией журнала
|
||||
- **THEN** в хранилище лежит приехавшая версия
|
||||
|
||||
#### Scenario: Версия из более ранней доставки не откатывает витрину
|
||||
|
||||
- **WHEN** две доставки несут одну тренировку с равными наборами полей, и
|
||||
свёрнута сперва более поздняя по журналу, затем более ранняя
|
||||
- **THEN** в хранилище лежит версия из более поздней доставки
|
||||
- **AND** тот же исход даёт свёртка в обратном порядке
|
||||
|
||||
#### Scenario: Обеднённая версия сохранённую не затирает
|
||||
|
||||
- **WHEN** та же тренировка приезжает повторно **без** `route`, который был у
|
||||
сохранённой, **и** с изменившимися значениями общих полей
|
||||
- **THEN** в хранилище остаётся сохранённая версия
|
||||
- **AND** факт учитывается счётчиком и записью `WARN` с идентификатором
|
||||
тренировки
|
||||
|
||||
#### Scenario: Усечённый маршрут сохранённый не затирает
|
||||
|
||||
- **WHEN** та же тренировка приезжает повторно с тем же набором полей, но
|
||||
`route` короче сохранённого
|
||||
- **THEN** в хранилище остаётся сохранённая версия
|
||||
- **AND** факт учитывается тем же счётчиком
|
||||
|
||||
#### Scenario: Две версии одной сущности в одном теле
|
||||
|
||||
- **WHEN** тело содержит два элемента секции с одним `id`
|
||||
- **THEN** исход не зависит от их порядка в массиве
|
||||
- **AND** счётчик различающихся версий тоже не зависит от их порядка
|
||||
|
||||
#### Scenario: Составной ключ не даёт коллизии отпечатка
|
||||
|
||||
- **WHEN** две витрины различаются только тем, где проходит граница между родом
|
||||
и идентификатором записи
|
||||
- **THEN** отпечатки не совпадают
|
||||
|
||||
#### Scenario: Несравнимые наборы полей не объединяются
|
||||
|
||||
- **WHEN** приехавшая версия несёт содержательный ключ, которого нет у
|
||||
сохранённой, и теряет содержательный ключ, который у сохранённой есть
|
||||
- **THEN** в хранилище остаётся сохранённая версия
|
||||
- **AND** факт учитывается тем же счётчиком
|
||||
|
||||
#### Scenario: Повторная свёртка того же журнала состояния не меняет
|
||||
|
||||
- **WHEN** те же доставки сворачиваются повторно в том же порядке
|
||||
- **THEN** содержимое сущностей не меняется
|
||||
|
||||
### Requirement: Отпечаток витрины покрывает все её сущности
|
||||
|
||||
Отпечаток витрины SHALL включать тренировки и записи наравне с часовыми
|
||||
объектами: он единственный оракул сходимости пересборки, и отпечаток одних
|
||||
объектов давал бы «состояние сошлось» при разъехавшихся тренировках — то есть
|
||||
ломался бы молча ровно тем изменением, которое добавляет данные.
|
||||
|
||||
В отпечаток идут координаты сущности и хеш её содержимого. Значений он
|
||||
раскрывать MUST NOT — как и для точек.
|
||||
|
||||
Порядок обхода SHALL быть детерминированным и заданным запросом, а не порядком
|
||||
строк в файле базы. Строки разных разделов SHALL различаться константным
|
||||
признаком раздела: без него строка одного раздела может совпасть со строкой
|
||||
другого, и два разных состояния дали бы один отпечаток. По той же причине
|
||||
составной ключ SHALL идти в отпечаток **отдельными полями с собственными
|
||||
длинами**, а не склейкой: склейка выполняется до взятия длины, и пара
|
||||
(`a`, `b/c`) даёт ту же строку, что (`a/b`, `c`).
|
||||
|
||||
Границу оракула стоит назвать вслух: в отпечаток идут координаты и хеш
|
||||
содержимого, а **колонки заголовка** сущности (имя, конец интервала, офсет,
|
||||
длительность) — нет. Они производны от содержимого, поэтому их расхождение
|
||||
означает изменившийся код извлечения заголовка, а не разъехавшееся состояние; но
|
||||
«отпечатки совпали» не является утверждением о них.
|
||||
|
||||
Все разделы SHALL читаться **одним снимком** базы. Отпечаток рабочей витрины
|
||||
снимается под живым приёмом, и запросы вне общей транзакции чтения дали бы смесь
|
||||
«объекты до» и «тренировки после» — то есть ложное расхождение у единственного
|
||||
оракула сходимости.
|
||||
|
||||
#### Scenario: Расхождение тренировок видно в отпечатке
|
||||
|
||||
- **WHEN** две витрины совпадают по часовым объектам, но содержимое одной
|
||||
тренировки различается
|
||||
- **THEN** отпечатки не совпадают
|
||||
|
||||
#### Scenario: Отпечаток одинаков при одинаковом содержимом
|
||||
|
||||
- **WHEN** та же витрина собрана повторно из того же журнала
|
||||
- **THEN** отпечаток совпадает
|
||||
|
||||
#### Scenario: Запись во время снятия отпечатка не смешивает разделы
|
||||
|
||||
- **WHEN** отпечаток снимается, а параллельно коммитится свёртка
|
||||
- **THEN** отпечаток отражает одно состояние базы, а не смесь снимков
|
||||
|
||||
|
||||
Reference in New Issue
Block a user