- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`; миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки с этими ключами - сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА — «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина расходилась бы с пересборкой молча - отпечаток витрины покрывает тренировки и записи и снимается одним снимком базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
26 KiB
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 содержит число отброшенных имён