- Правило покрытия получило второй разряд (условный, как у точек), запрет вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и ряд из null больше не затирают маршрут. Победитель внутри доставки стал функцией множества версий — общим помощником с точками, — а провенанс поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину к прежнему содержимому. - Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает на старте; текст ошибки разбора не несёт значений из тела. - Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты: безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя был квадратичен по числу присланных версий одного ключа.
707 lines
54 KiB
Markdown
707 lines
54 KiB
Markdown
# parsing Specification
|
||
|
||
## Purpose
|
||
TBD - created by archiving change razbor-metrik-v-obekty. Update Purpose after archive.
|
||
## Requirements
|
||
### Requirement: Разбор секции метрик
|
||
|
||
Система SHALL разбирать секцию `data.metrics` тела доставки Health Auto Export
|
||
в точки. Точка несёт имя метрики, единицы, слой, метку времени и содержимое в
|
||
том виде, в каком его прислал HAE.
|
||
|
||
Хранимая форма точки — **исходные байты**, как они пришли в теле доставки.
|
||
Система MUST NOT пересобирать содержимое повторной сериализацией разобранных
|
||
значений: обход через `map[string]any` теряет литерал (`1.0` становится `1`,
|
||
целые больше 2^53 сдвигаются, невалидный UTF-8 заменяется на U+FFFD), и потеря
|
||
не видна тестам на фикстурах — они сравнивают разобранное с разобранным.
|
||
|
||
Отсюда же следует, что «служебных» полей у точки нет: отбрасывать нечего,
|
||
нормализованное время добавляется рядом с исходным содержимым, а не вместо.
|
||
|
||
#### Scenario: Метрика с точками разбирается в точки
|
||
|
||
- **WHEN** тело содержит `data.metrics[]` с непустым `data[]`
|
||
- **THEN** каждая точка с непустым `date` становится точкой хранилища
|
||
- **AND** её содержимое сохраняется исходными байтами, без пересборки
|
||
|
||
#### Scenario: Незнакомая метрика не ломает разбор
|
||
|
||
- **WHEN** приходит метрика с именем, которого разбор не знает
|
||
- **THEN** её точки разбираются наравне с остальными
|
||
- **AND** разбор не завершается ошибкой
|
||
|
||
#### Scenario: Точка без метки времени пропускается
|
||
|
||
- **WHEN** точка не содержит `date` либо `date` не разбирается ни одним из
|
||
поддерживаемых форматов
|
||
- **THEN** точка не попадает в хранилище
|
||
- **AND** факт учитывается в итоге разбора доставки
|
||
|
||
#### Scenario: Точка с нечитаемым концом интервала пропускается
|
||
|
||
- **WHEN** точка несёт `end`, который не разбирается
|
||
- **THEN** точка не попадает в хранилище
|
||
- **AND** факт учитывается отдельным счётчиком
|
||
|
||
Вырождать такую точку в мгновенную нельзя: две записи с общим началом получили
|
||
бы одну координату, и одна исчезла бы молча. Тело остаётся в архиве.
|
||
|
||
### Requirement: Вывод слоя гранулярности
|
||
|
||
Система SHALL выводить слой точки из **выравнивания меток времени**, а не из
|
||
заголовка доставки. Заголовок `automation-aggregation` непригоден: значение
|
||
`Default` соответствует трём разным режимам выгрузки.
|
||
|
||
Выводимые слои: `raw` (метка на произвольной секунде), `minute` (секунды
|
||
нулевые), `hour` (секунды и минуты нулевые). Слой `day` в перечисление входит,
|
||
но **не выводится** — он назначается схемам с фиксированной гранулярностью
|
||
(см. «Разделение схем под одним именем метрики»).
|
||
|
||
Выравнивание SHALL считаться по метке в **исходной зоне**, а не по метке в UTC.
|
||
HAE строит сетку по местному времени; ровный местный час в зоне со смещением на
|
||
половину (`+0530`, `+0545`, `+0930`) даёт UTC-метку на середине часа, и часовая
|
||
выгрузка целиком уехала бы в слой `minute` — где столкнулась бы с настоящей
|
||
минутной автоматизацией и завысила сумму минутного слоя вдвое.
|
||
|
||
Слоем метрики SHALL становиться **самое мелкое** выравнивание среди её меток, а
|
||
не преобладающее. Измерено: у плотных метрик выравнивания перемешаны
|
||
(`active_energy` — 1320 минутных меток и 21 часовая, `heart_rate` — 654
|
||
посекундных и 10 минутных), потому что метка ровно на часе одновременно
|
||
является и минутной. Метрика, у которой хоть одна метка стоит на середине часа,
|
||
часовой не является.
|
||
|
||
Разделение схем под одним именем выполняется **до** вывода слоя, и точки с
|
||
назначенным слоем в определении преобладающего слоя доставки не участвуют:
|
||
суточных сводок сна бывает больше порога плотности, и их полуночные метки
|
||
иначе назначили бы всей доставке слой `hour`.
|
||
|
||
Классификация MUST быть **по метрике внутри доставки**, а не по доставке
|
||
целиком: при перенастройке автоматизации приезжают смешанные доставки, и
|
||
отнесение такой доставки к одному слою складывает минутные точки с
|
||
посекундными.
|
||
|
||
#### Scenario: Плотная метрика классифицируется сама
|
||
|
||
- **WHEN** в доставке у метрики не меньше десяти точек с метками
|
||
- **THEN** слой определяется выравниванием её собственных меток
|
||
|
||
#### Scenario: Зона с получасовым смещением не делает часовую выгрузку минутной
|
||
|
||
- **WHEN** метки стоят на ровном местном часе, а смещение зоны равно `+0530`
|
||
- **THEN** слой метрики `hour`
|
||
|
||
#### Scenario: Одна метка на середине часа делает метрику минутной
|
||
|
||
- **WHEN** у плотной метрики десять меток стоят ровно на часе, а одна — на
|
||
середине часа
|
||
- **THEN** слой метрики `minute`, а не `hour`
|
||
|
||
#### Scenario: Редкая метрика наследует преобладающий слой
|
||
|
||
- **WHEN** в доставке у метрики меньше десяти точек
|
||
- **THEN** она получает самый мелкий слой среди плотных метрик этой доставки
|
||
- **AND** её собственное выравнивание во внимание не принимается
|
||
|
||
#### Scenario: Доставка, где всем метрикам слой назначен схемой
|
||
|
||
- **WHEN** в доставке нет метрик, которым слой надо выводить, — все точки
|
||
принадлежат схемам с фиксированной гранулярностью
|
||
- **THEN** доставка сохраняется, а слой доставки не выводится и не требуется
|
||
|
||
Иначе доставка автоматизации, настроенной только на сон, отвергалась бы
|
||
целиком — и необратимо: исход детерминирован, и пересборка повторяла бы его
|
||
вечно.
|
||
|
||
#### Scenario: В доставке нет плотных метрик
|
||
|
||
- **WHEN** ни у одной метрики доставки нет десяти точек
|
||
- **THEN** слой наследуется от последнего надёжно выведенного слоя той же
|
||
автоматизации (`automation-id`) среди доставок, **предшествующих** этой
|
||
- **AND** если наследовать нечего, слой берётся из **надёжного** заголовка
|
||
(`Minutes` → `minute`, `Hours` → `hour`)
|
||
|
||
Граница «предшествующих» обязательна: слой обязан быть функцией от префикса
|
||
журнала. Наследование от последней доставки вообще делает свёртку зависящей от
|
||
истории, и пересборка даёт не то состояние, что живой приём — измерено на
|
||
архиве, 1737 объектов против 1742.
|
||
|
||
#### Scenario: Пересборка журнала даёт то же состояние
|
||
|
||
- **WHEN** те же доставки сворачиваются повторно в том же порядке
|
||
- **THEN** число объектов и их содержимое не меняются
|
||
|
||
#### Scenario: Наследовать нечего и заголовок ненадёжен
|
||
|
||
- **WHEN** плотных метрик нет, предыдущего слоя автоматизации нет, а заголовок
|
||
равен `Default`
|
||
- **THEN** точки доставки не сохраняются, а исход учитывается счётчиком и
|
||
записью `WARN`
|
||
- **AND** тело остаётся в архиве, откуда доставку подберёт пересборка
|
||
|
||
Молчаливый выбор `raw` в этой ветке недопустим: заголовок `Default` наблюдался
|
||
одновременно у посекундного, минутного и часового режимов, поэтому он не
|
||
доказывает ничего, а призрачный `raw`-разрез попадёт в каталог и в правило
|
||
Read API «самый мелкий слой, покрывающий диапазон».
|
||
|
||
#### Scenario: Выведенный слой расходится с надёжным заголовком
|
||
|
||
- **WHEN** выведенный слой не совпадает с заголовком `Minutes` или `Hours`
|
||
- **THEN** система пишет запись уровня `WARN`
|
||
- **AND** сохраняет точки по выведенному слою, а не по заголовку
|
||
|
||
#### Scenario: Заголовок `Default` в сравнении не участвует
|
||
|
||
- **WHEN** заголовок доставки равен `Default`
|
||
- **THEN** расхождение не фиксируется и `WARN` не пишется
|
||
|
||
Иначе сигнал утонул бы в собственном шуме: `Default` не означает режима, и
|
||
сравнение с ним давало бы `WARN` на каждой доставке потока в пять минут.
|
||
|
||
### 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 разводить на разные имена метрики те схемы, которые Health Auto
|
||
Export шлёт под одним именем, чтобы одно имя означало одну схему.
|
||
|
||
Под именем `sleep_analysis` приезжают две несовместимые схемы: поэпизодная
|
||
(`start`/`end`/`value`/`qty`) и суточная сводка
|
||
(`totalSleep`/`core`/`rem`/`deep`/`awake` с меткой на местной полуночи). Общих
|
||
полей, кроме `date` и `source`, у них нет.
|
||
|
||
Схема точки SHALL определяться по самой точке, а не по её месту в исходном
|
||
массиве. Список разобранных точек отфильтрован пропусками, и соответствие по
|
||
индексу съезжало бы от одной пропущенной точки: эпизод уезжал бы под имя
|
||
суточной сводки со слоем `day`, сводка — под имя эпизода. Метрика и слой входят
|
||
в координату, поэтому ошибка необратима — точки из объекта не удаляются, и
|
||
пересборка воспроизвела бы её.
|
||
|
||
#### Scenario: Пропущенная точка не сдвигает разметку схем
|
||
|
||
- **WHEN** в метрике `sleep_analysis` перед суточной сводкой стоит точка без
|
||
разбираемой метки
|
||
- **THEN** сводка всё равно сохраняется под именем `sleep_analysis_summary` со
|
||
слоем `day`
|
||
|
||
#### Scenario: Поэпизодная запись сна
|
||
|
||
- **WHEN** точка `sleep_analysis` содержит поле `value`
|
||
- **THEN** она сохраняется под именем `sleep_analysis`
|
||
|
||
#### Scenario: Суточная сводка сна
|
||
|
||
- **WHEN** точка `sleep_analysis` содержит поле `totalSleep`
|
||
- **THEN** она сохраняется под именем `sleep_analysis_summary`
|
||
- **AND** её слой фиксирован как `day`, а не выводится из выравнивания
|
||
|
||
### Requirement: Канонизация содержимого
|
||
|
||
Система SHALL вычислять каноническую форму содержимого точки для сравнения и
|
||
хеширования: сортировка ключей и округление чисел до двенадцати значащих цифр.
|
||
|
||
Каноническая форма существует **только в момент вычисления хеша** и хранимую
|
||
форму не заменяет никогда: хранится исходные байты (см. «Разбор секции
|
||
метрик»). Числа при канонизации читаются литералом, а не через `float64`, —
|
||
иначе округление применится к уже испорченному значению.
|
||
|
||
Сортировка ключей — свойство `encoding/json`, своей реализации не требует.
|
||
Собственным остаётся только округление.
|
||
|
||
Без округления сравнение бесполезно: 63% повторно приехавших точек различались
|
||
последним разрядом double при одинаковом измерении.
|
||
|
||
#### Scenario: Повтор с иным порядком ключей опознаётся как тот же
|
||
|
||
- **WHEN** та же точка приезжает с другим порядком ключей в JSON
|
||
- **THEN** её каноническая форма совпадает с сохранённой
|
||
|
||
#### Scenario: Дребезг последнего разряда не считается изменением
|
||
|
||
- **WHEN** значение отличается только за пределами двенадцатой значащей цифры
|
||
- **THEN** каноническая форма совпадает с сохранённой
|
||
|
||
### Requirement: Разбор не влияет на код ответа приёма
|
||
|
||
Система MUST сохранять правило «сохранили — значит приняли»: исход разбора не
|
||
меняет код ответа на доставку.
|
||
|
||
Разбор идёт **после** ответа, поэтому исход виден не в ответе и не в момент
|
||
ответа, а асинхронно — в `delivery.parse_status` и в записи лога. Когда именно
|
||
отдаётся ответ и кто сворачивает принятое, определяет capability `ingest`;
|
||
здесь нормируется только то, что от разбора код ответа не зависит.
|
||
|
||
#### Scenario: Содержимое не разобралось
|
||
|
||
- **WHEN** тело сохранено в архив, но разбор его содержимого не удался
|
||
- **THEN** ответ на приём остаётся `200`
|
||
- **AND** исход виден в `delivery.parse_status` и в записи лога
|
||
|
||
### 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** содержимое непокрытой секции в результат разбора не попадает
|
||
|
||
### Requirement: Границы списка непокрытых секций
|
||
|
||
Список непокрытых ключей MUST быть ограничен — не больше 32 имён и не больше
|
||
64 байт на имя: имена приходят из тела, которым отправитель управляет целиком.
|
||
Срабатывание любой из границ MUST быть видно вызывающему — молчаливое усечение
|
||
превратило бы список в уверенный, но неполный ответ на вопрос «что останется
|
||
потерянным, если тело удалить».
|
||
|
||
Число имён сверх предела отдаётся счётчиком. Имя длиннее предела обрезается по
|
||
границе рун, к обрезанному приписывается маркер `…` — сверх предела, а не внутри
|
||
него. Обрезка не инъективна, поэтому обрезанное имя сравнению со словарём
|
||
известных секций не подлежит.
|
||
|
||
Предел длины считается по байтам **декодированного** имени: escape-
|
||
последовательности JSON к этому моменту уже разобраны.
|
||
|
||
#### Scenario: Ключей больше предела
|
||
|
||
- **WHEN** объект `data` содержит 40 непокрытых ключей
|
||
- **THEN** список содержит 32 имени
|
||
- **AND** число отброшенных имён отдано отдельным счётчиком
|
||
|
||
#### Scenario: Имя ключа длиннее предела
|
||
|
||
- **WHEN** непокрытый ключ длиннее 64 байт
|
||
- **THEN** в списке лежит имя, обрезанное по границе рун, с маркером `…`
|
||
|
||
### 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 разбирать секции тела, элементы которых несут собственный `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 стоить одного поля, а не сущности.**
|
||
Каждое поле заголовка читается мягко: строка берётся, когда значение является
|
||
строкой, и считается отсутствующей во всех прочих случаях. Правило уже записано
|
||
для длительности («нечисловое значение — это пропуск ОДНОГО поля, а не сломанная
|
||
сущность») и распространяется на весь заголовок. Иначе `name`, приехавшее
|
||
числом, уносит тренировку вместе с маршрутом, а доставка при этом числится
|
||
разобранной.
|
||
|
||
Мягкость MUST достигаться конструкцией, которая **не полагается на дозаполнение
|
||
остальных полей** библиотекой разбора: `encoding/json` при несовпадении типа
|
||
«skips that field and completes the unmarshaling as best it can», но тут же
|
||
оговаривает, что дозаполнение полей **после** проблемного не гарантировано.
|
||
Разбор, построенный на распознавании ошибки типа постфактум, перестал бы быть
|
||
функцией тела: одна и та же тренировка давала бы разный заголовок.
|
||
|
||
Граница правила называется вслух: оно закрывает смену **типа** значения, но не
|
||
смену **формата строки**. Наблюдавшийся дрейф — формата дат (разбор дат уже
|
||
зависит от секции пакета), и метка в незнакомом формате по-прежнему уносит
|
||
сущность целиком; закрыть это может только хранение сущности с неразобранной
|
||
меткой, а это отдельная задача. Пропуск при этом перестаёт быть невидимым: он
|
||
доходит до учётной записи доставки.
|
||
|
||
Идентификатор исключением из мягкости MUST быть: сущность без строкового `id`
|
||
не адресуема, и приведение чужого нестрокового значения к строке было бы
|
||
выдумыванием идентичности за источник. Такая сущность пропускается тем же
|
||
счётчиком, что и сущность без `id`.
|
||
|
||
Началом сущности при **присутствующем, но непрочитанном** `start` подставляться
|
||
`date` MUST NOT — включая `start: null`.
|
||
«Значение не той формы» и «значения нет» здесь различаются: фолбэк на `date`
|
||
существует для сущностей, у которых `start` не прислан вовсе, а подстановка
|
||
другого поля вместо непонятого даёт метку **другого момента времени**, ничем не
|
||
отличимую от настоящей. Такой `start` SHALL считаться неразбираемой меткой —
|
||
тем же исходом и тем же счётчиком, что метка незнакомого формата. Различать
|
||
надо именно «ключ был», а не «значение не той формы»: `null` тоже не даёт
|
||
строки, и без этого различения он молча уводил бы тренировку на другой момент
|
||
времени.
|
||
|
||
Мягкое чтение SHALL задавать поле целиком на каждое вхождение ключа, а не
|
||
накапливать признаки между вызовами. JSON допускает повтор ключа с семантикой
|
||
«побеждает последнее», и разбор ей уже следует; накопленный признак сделал бы
|
||
заголовок функцией истории вызовов, а не тела.
|
||
|
||
Элемент секции, не являющийся объектом JSON, SHALL уходить в счётчик «не
|
||
разобралось как объект» — включая `null`. Разбор в структуру на `null` ошибки не
|
||
даёт, поэтому такой элемент без явной проверки попадал бы в счётчик «нет `id`»,
|
||
и сменившаяся форма СЕКЦИИ диагностировалась бы как сменившаяся форма
|
||
ИДЕНТИФИКАТОРА — ради различения которых два счётчика и заведены.
|
||
|
||
Длительность 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 пропускаться со
|
||
счётчиком, не роняя разбор остального: тело остаётся в архиве, и доставку
|
||
подберёт пересборка, когда разбор научится её понимать. Счётчики пропусков 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` и `start` поле `name` приехало
|
||
числом
|
||
- **THEN** тренировка попадает в результат разбора с пустым именем
|
||
- **AND** её содержимое сохраняется дословно, включая маршрут
|
||
|
||
#### Scenario: Конец не той формы не уносит тренировку
|
||
|
||
- **WHEN** у тренировки с корректными `id` и `start` поле `end` приехало числом
|
||
- **THEN** тренировка попадает в результат разбора, а конец равен началу
|
||
|
||
#### Scenario: Сущность без идентификатора пропускается
|
||
|
||
- **WHEN** элемент покрытой секции не несёт `id`, либо `id` пуст, либо `id`
|
||
приехал не строкой
|
||
- **THEN** сущность в результат разбора не попадает
|
||
- **AND** факт учитывается счётчиком, а разбор остальных сущностей продолжается
|
||
|
||
#### Scenario: Элемент секции не является объектом
|
||
|
||
- **WHEN** элемент покрытой секции не разбирается как объект JSON
|
||
- **THEN** сущность в результат разбора не попадает
|
||
- **AND** факт учитывается **отдельным** счётчиком, а соседние сущности
|
||
разбираются как обычно
|
||
|
||
Отдельным, а не общим с «нет `id`»: доставка, где не разобрался сам элемент, —
|
||
это сменившаяся форма секции, а доставка без `id` — сменившаяся форма
|
||
идентификатора. Ронять из-за такого элемента всю доставку нельзя тем более:
|
||
`failed` фоновая свёртка не подбирает никогда, и вместе с одной кривой
|
||
тренировкой в него уехали бы записи `stateOfMind` той же доставки.
|
||
|
||
#### Scenario: Элемент секции не объект — свой счётчик
|
||
|
||
- **WHEN** элемент покрытой секции пришёл как `null`, строка, число или массив
|
||
- **THEN** факт учитывается счётчиком «не разобралось как объект»
|
||
- **AND** счётчик «нет `id`» не растёт
|
||
|
||
#### Scenario: Повтор ключа метки решается последним значением
|
||
|
||
- **WHEN** у элемента ключ `start` встречается дважды, и валидная метка стоит
|
||
последней
|
||
- **THEN** сущность попадает в результат разбора с этой меткой
|
||
|
||
#### Scenario: Сущность без разбираемой метки времени пропускается
|
||
|
||
- **WHEN** элемент покрытой секции несёт `id`, но его метка времени не
|
||
разбирается ни одним из поддерживаемых форматов либо пришла не строкой
|
||
(включая `null`)
|
||
- **THEN** сущность в результат разбора не попадает
|
||
- **AND** факт учитывается счётчиком
|
||
- **AND** поле `date` вместо непонятого `start` не подставляется
|
||
|
||
#### 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** сущностей из неё разбор не отдаёт
|
||
|
||
### Requirement: Диагностика разбора не несёт значений из тела
|
||
|
||
Сообщение об ошибке разбора MUST NOT содержать значений из тела доставки. Оно
|
||
SHALL называть **тип** встреченного токена и смещение во входе — по смещению
|
||
место находится в теле, лежащем в архиве, а значение из тела в логе не имеет
|
||
права быть в принципе.
|
||
|
||
Тип SHALL называться словарём JSON (`object`, `array`, `string`, `number`,
|
||
`bool`, `null`), а не именем типа языка реализации: имя типа для делимитера не
|
||
говорит ничего — какая скобка встретилась вместо ожидаемой, из него не следует,
|
||
— а сам делимитер принадлежит фиксированному набору и содержимого не раскрывает,
|
||
поэтому печатается значением.
|
||
|
||
Смещение SHALL указывать на место **перед** виновным токеном и от длины его
|
||
значения зависеть MUST NOT. Декодер сообщает позицию как конец последнего
|
||
возвращённого токена, поэтому взятая после чтения она отличалась бы от начала
|
||
проблемы ровно на длину значения — то есть на восемь мегабайт в том самом
|
||
случае, ради которого требование написано, и обещание «место находится в теле»
|
||
не выполнялось бы.
|
||
|
||
Это не стиль, а тот же инвариант, что уже записан для точек: данные о здоровье
|
||
чувствительнее токенов, тела запросов пишутся только на `DEBUG` и с обрезкой.
|
||
Подстановка токена целиком инвариант обходит: тело в 8 МиБ даёт текст ошибки в
|
||
8 МиБ, который уходит атрибутом `error` на уровень `WARN` — то есть содержимое
|
||
доставки оказывается в логе полностью и без обрезки.
|
||
|
||
Предел SHALL держаться самим сообщением, а не обрезкой на стороне
|
||
логирующего: обрезка живёт в другом месте и о новой ошибке разбора не узнает.
|
||
|
||
Правило SHALL распространяться и на **чужие** причины: ошибка библиотеки разбора
|
||
кладёт в текст литерал значения, поэтому причина, приходящая извне, обрезается по
|
||
названной длине на границе. Тот же предел SHALL действовать на проверке формы
|
||
конверта при приёме — она пользуется той же библиотекой, и её отказ логируется на
|
||
`DEBUG`, где инвариант тоже требует обрезки.
|
||
|
||
#### Scenario: Огромное значение не доезжает до текста ошибки
|
||
|
||
- **WHEN** тело содержит на месте ожидаемого объекта строку в несколько
|
||
мегабайт
|
||
- **THEN** разбор завершается ошибкой
|
||
- **AND** длина текста ошибки не зависит от длины этого значения
|
||
- **AND** текст называет тип токена словарём JSON и смещение перед токеном
|
||
- **AND** смещение не меняется, если то же значение сделать длиннее
|
||
|