Files
av f8200f7f80 feat: разбор и хранение тренировок и состояния разума
- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной
  строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`;
  миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки
  с этими ключами
- сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет
  содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а
  при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА —
  «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина
  расходилась бы с пересборкой молча
- отпечаток витрины покрывает тренировки и записи и снимается одним снимком
  базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
2026-08-02 13:05:16 +03:00

26 KiB
Raw Permalink Blame History

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 содержит число отброшенных имён