feat: разбор и хранение тренировок и состояния разума
- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`; миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки с этими ключами - сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА — «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина расходилась бы с пересборкой молча - отпечаток витрины покрывает тренировки и записи и снимается одним снимком базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
This commit is contained in:
@@ -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** содержит число отброшенных имён
|
||||
Reference in New Issue
Block a user