- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`; миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки с этими ключами - сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА — «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина расходилась бы с пересборкой молча - отпечаток витрины покрывает тренировки и записи и снимается одним снимком базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
57 KiB
storage Specification
Purpose
TBD - created by archiving change razbor-metrik-v-obekty. Update Purpose after archive.
Requirements
Requirement: Идентичность точки по координатам
Система SHALL адресовать точку координатами
метрика + слой + начало + конец. У точки-измерения конец равен началу; у
точки-интервала — концу интервала. Ключ MUST быть одной формы для всех точек:
интервальная и точечная формы не встречаются вперемешку внутри одной метрики
одной доставки (проверено на всём корпусе), поэтому ветвление по «классу
метрики» не нужно и вводить его MUST NOT.
Поле source в ключ входить MUST NOT: оно нестабильно — то же измерение с тем
же значением приезжает то как Apple Watch Ultra 3|iPad (Anton), то как
Apple Watch Ultra 3, потому что Health переосмысливает атрибуцию задним
числом.
Начало точки берётся из start, а при его отсутствии — из date; конец — из
end, а при его отсутствии — из начала. Измерено: start, когда он есть,
всегда совпадает с date (ноль исключений на 22 метриках), поэтому правило
не вводит второго источника метки — оно лишь закрывает случай, когда HAE
перестанет их дублировать.
Час объекта определяется по началу точки: интервал пересекает границы часов, и любой другой выбор сделал бы принадлежность объекту зависящей от длительности.
Ключ по одной метке проверялся и отвергнут: он схлопывает записи сна. Измерено
на всех 94 доставках — 170 координат против 174 и 33 столкновения внутри
одной доставки, где received_at общий, тай-брейк по нему неприменим в
принципе, и исход решал бы порядок элементов в JSON-массиве, а он нестабилен.
При этом разные интервалы под одной меткой всегда несут разное содержимое
(проверено по всем метрикам), то есть ключ с интервалом ничего не задваивает.
Идентичность по хешу содержимого проверялась и отвергнута: она задваивала минутный слой целиком — 120 точек в часе вместо 60.
Scenario: Повторная доставка той же точки ничего не меняет
- WHEN точка с теми же координатами и тем же содержимым приезжает снова
- THEN хранилище не изменяется
Scenario: Смена источника не создаёт вторую точку
- WHEN точка с теми же координатами приезжает с другой строкой
source - THEN она остаётся одной точкой, а не превращается в две
Scenario: Записи с одной меткой и разными интервалами не схлопываются
- WHEN в доставке приходят точки
sleep_analysisс одинаковымdateи разными парамиstart/end - THEN каждая сохраняется отдельной точкой
Scenario: Повтор записи в следующей доставке не задваивает
- WHEN точка с тем же началом и концом приезжает следующей доставкой
- THEN она остаётся одной точкой
Scenario: Точка-измерение адресуется вырожденным интервалом
- WHEN точка не несёт
end - THEN её конец равен началу, и ключ имеет ту же форму, что у интервала
Requirement: Разрешение столкновений по полноте
Когда по одним координатам приходят разные содержимые, система SHALL оставлять более полную точку — ту, чьё множество ключей с непустым значением является строгим надмножеством множества другой, — а не последнюю пришедшую. Иначе бедная доставка стирает у богатой поля, которых сама не несёт.
Полнота SHALL сравниваться множествами, а не их размером. Число сравнимо всегда, и потому счётчик даёт ответ там, где ответа нет: точка с пятью полями, не несущими содержания, побеждала бы настоящее измерение с двумя полями и стирала бы его безвозвратно.
Измерено (находка 49): настоящих столкновений 2 897 из 444 256 координат (0.65%); из них 981 различаются набором полей — это и есть область правила полноты, — 1 916 несут равные наборы и разные значения, где исход решает тай-брейк, а несравнимых наборов ноль.
Пустым значением MUST считаться null, пустая строка, число, равное нулю (в
любой записи), пустой объект и пустой массив: поле без содержания не делает
точку полнее точки, где этого поля нет вовсе. Пустота MUST определяться по
разобранному значению, а не по байтам: 0, 0.0, 0e0, -0 и { } — та же
пустота, что 0 и {}.
false пустотой MUST NOT считаться: для булева поля это одно из двух значений,
а не отсутствие содержания (isIndoor: false — тренировка на улице).
Поле source в множество не входит — оно нестабильно и переписывается задним
числом (находка 36), так что о полноте измерения ничего не говорит.
Содержимое, которое не разбирается как JSON-объект, SHALL нести пустое множество ключей: так оно проигрывает любой точке с содержанием и не загрязняет наблюдение о несравнимых наборах.
Надмножество побеждает только тогда, когда оно несёт то же содержание:
значения ключей, содержательных у обеих точек, MUST совпадать (с точностью до
канонической формы). Иначе точки несут разные измерения, и надмножество имён
о полноте не говорит ничего — такая пара MUST разрешаться как равнополная.
Без этого условия точка {date, qty:0.001, p1:null, p2:null} вытесняла бы
{date, qty:72.5}, то есть точка, где ни одно значение не измерение,
стирала бы измерение — ровно то, ради отрицания чего правило переписано.
Если множества ключей с непустым значением равны и значения совпали,
система SHALL сравнить множества всех ключей, кроме source, и оставить
строгое надмножество. Без этого разряда правило теряло бы поля там, где
заведено их беречь: точка {date, qty:10, Min:0, Max:0} и точка
{date, qty:10} несут одинаковое содержание, и Min с Max исчезли бы из
витрины по жребию. Несравнимость на этом разряде исходом MUST NOT быть:
лишние ключи там заведомо пусты, объединять в них нечего.
Если равны и эти множества, а значения различаются, исход MUST быть детерминированным и не зависеть от порядка, в котором доставки дошли до хранилища: свёртка по журналу обязана давать то же состояние, что приём в реальном времени.
Победитель MUST быть функцией множества точек координаты, а не порядка их поступления. Попарная свёртка этого не даёт: полнота — частичный порядок, тай-брейк — тотальный, и вместе они образуют нетранзитивное отношение победы (A превосходит B по полноте, B бьёт C тай-брейком, C бьёт A тай-брейком). При таком цикле повторная свёртка одной и той же доставки меняет содержимое объекта, и витрина перестаёт быть функцией журнала. Поэтому система SHALL отбросить кандидатов, превзойдённых по полноте кем-то другим, и выбрать победителя среди оставшихся по тотальному порядку — обе операции зависят только от состава множества.
Сравнение по received_at для этого не годится: у сохранённой точки нет
провенанса — ни времени приёма, ни идентификатора доставки, — и сравнивать
не с чем. Детерминизм обеспечивается свойством самих значений (например,
порядком канонических форм), а не порядком событий.
Если множества несравнимы — каждое несёт ключ с непустым значением,
которого нет у другого, — система SHALL выбрать победителя тем же
детерминированным правилом, что и при равных множествах, и MUST оставить
наблюдение: счётчик в итоге разбора доставки, координаты объекта и запись
WARN без значений точки. Несравнимый набор — частный случай столкновения:
счётчик перезаписей растёт вместе с ним, а координаты попадают в оба списка.
Объединять поля двух точек система SHALL NOT: на живом потоке несравнимых наборов не встретилось ни разу (0 из 2 897 столкновений, при обоих определениях пустоты), и реализация правила, которое никогда не срабатывает, стоила бы больше, чем счётчик, который скажет, если оно наступит.
Победителем SHALL оставаться одна из пришедших точек дословно: правило выбирает, а не конструирует. Каноническая форма существует только в момент сравнения — вернуть её вместо исходных байт значило бы сохранить округлённое число вместо присланного.
Scenario: Бедная точка не стирает поля богатой
- WHEN сохранена точка с
Avg,Min,Maxиcontext - AND по тем же координатам приезжает точка только с
Avg,MinиMax - THEN сохранённая точка остаётся с
context
Scenario: Поля без содержания полноты не добавляют
- WHEN сохранена точка с пятью полями, значения которых
0,{}и[] - AND по тем же координатам приезжает точка с
dateи ненулевымqty - THEN остаётся точка с
dateиqty
Scenario: При равном содержании поля не теряются
- WHEN сохранена точка
{date, qty, Min:0, Max:0} - AND по тем же координатам приезжает точка
{date, qty}с другимqty - THEN остаётся точка с
MinиMax
Scenario: Одинаково полные точки с разными значениями
- WHEN по одним координатам приходят две точки с одинаковыми множествами ключей и разными значениями
- THEN исход определяется детерминированно и не зависит от порядка воспроизведения доставок
Scenario: Несравнимые множества считаются, а не сливаются
- WHEN по одним координатам приходят две точки, каждая из которых несёт ключ с непустым значением, которого нет у другой
- THEN остаётся ровно одна точка, выбранная детерминированно
- AND счётчик несравнимых наборов в итоге разбора доставки растёт
- AND система пишет
WARNс координатами объекта и без значений точки
Scenario: Содержимое, которое не разбирается в объект
- WHEN по координатам сталкиваются точка с непустыми полями и содержимое, не разбирающееся как JSON-объект
- THEN остаётся точка с полями
- AND счётчик несравнимых наборов не растёт
Столкновением SHALL считаться расхождение канонических форм, а не байтов. Байты нестабильны — ради этого канонизация и заведена: из 81 952 повторно приехавших точек 67 534 различаются лишь порядком ключей, ещё 63% — последним разрядом double. Побайтовое сравнение давало бы тысячи ложных срабатываний на каждом глубоком проходе, и настоящий отказ правила стал бы неотличим от нормы.
Scenario: Столкновение с различием содержимого оставляет след
- WHEN по одним координатам сохраняется точка, каноническая форма которой отличается от уже сохранённой
- THEN система пишет запись уровня
WARNбез значений точки - AND запись несёт координаты объекта: метрику, слой и час
- AND увеличивает счётчик перезаписей в итоге разбора доставки
Scenario: Дребезг сериализации столкновением не считается
- WHEN та же точка приезжает с другим порядком ключей или отличаясь последним разрядом числа
- THEN счётчик перезаписей не растёт и
WARNне пишется
Без этого следа допущение «меньше полей не значит новее» не получит ни одного наблюдения, а отказ правила будет неотличим от нормальной работы до сверки с экспортом Apple — то есть месяцами.
Requirement: Хранение часовыми объектами
Система SHALL хранить точки часовыми объектами с ключом
метрика + слой + час (UTC). Содержимое объекта — сжатый gzip блоб; точки
внутри упорядочены по времени.
Объект SHALL нести единицы измерения метрики. Внутри точки их нет — они
живут на уровне метрики (проверено: поле units не встретилось ни в одной
точке за 89 доставок), поэтому дословное хранение точек их не сохраняет. Без
колонки единицы восстановимы только из архива, а для метрик, переставших
приходить, — теряются навсегда.
Объект SHALL нести границы содержимого (первая и последняя метка) и идентификатор доставки, создавшей его. Первое нужно каталогу разрезов, чтобы не разжимать каждый блоб ради диапазона; второе — провенанс для разбора слияний.
Запись — чтение объекта, слияние точек, запись обратно. Точки из объекта
MUST NOT удаляться. Содержимое объекта SHALL сериализоваться без
HTML-экранирования: &, < и > внутри точки обязаны храниться теми же
байтами, какими пришли, иначе «точка хранится дословно» перестаёт быть правдой,
а сравнение с последующей доставкой той же точки промахивается навсегда.
Доставка SHALL сворачиваться одной транзакцией. Транзакция на объект давала недетерминированное частичное состояние: обход групп рандомизирован, и при отказе посреди доставки набор уже записанных объектов каждый раз другой (измерено: восемь прогонов одной доставки — семь разных состояний). Это ломает инвариант «состояние пересобираемо».
Единицы измерения MUST NOT переписываться молча: при расхождении сохранённых и
пришедших единиц остаётся сохранённое значение, факт учитывается счётчиком и
попадает в запись уровня WARN. Внутри точки единиц нет, и у ранее сохранённых
точек не остаётся ничего, по чему их единицы восстановимы.
Scenario: Отказ посреди доставки не оставляет части объектов
- WHEN свёртка доставки прерывается на середине
- THEN не записывается ни один объект этой доставки
Scenario: Смена единиц не переподписывает сохранённые точки
- WHEN в объект приезжают точки в единицах, отличных от сохранённых
- THEN единицы объекта остаются прежними
- AND факт учитывается счётчиком и записью
WARN
Scenario: Точки за один час ложатся в один объект
- WHEN приходят точки одной метрики и слоя за один час UTC
- THEN они хранятся одним объектом
Scenario: Дозапись в существующий час
- WHEN приходят новые точки за уже существующий час
- THEN объект перечитывается, точки сливаются, объект записывается обратно
- AND ранее сохранённые точки остаются в объекте
Requirement: Хеш как детектор изменений
Система SHALL хранить хеш канонической формы объекта и пропускать запись, если хеш не изменился. Хеш — детектор, а не ключ.
Это то, что делает широкие проходы синхронизации дешёвыми: глубокий проход переприсылает неделю, но почти все сравнения сходятся и записи не происходит.
Scenario: Повторная присылка того же часа не пишет в базу
- WHEN приезжает доставка, целиком повторяющая уже сохранённый час
- THEN хеш совпадает и запись не выполняется
Requirement: Признак запечатанного часа
Система SHALL хранить признак sealed у часового объекта и SHALL реагировать
на изменение запечатанного объекта сигналом, а не отказом.
Правило перевода часа в sealed в этой дельте не определяется: порог
глубины досчёта ставится по наблюдениям, которых пока нет (наблюдалось до
22 минут). До появления правила признак остаётся невыставленным, и сценарий
ниже проверяется только явной установкой в тесте — это осознанная граница, а
не упущение.
Scenario: Изменение запечатанного часа
- WHEN приходят точки за час, помеченный
sealed - THEN система пишет запись уровня
WARN - AND данные всё равно сохраняются
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 содержит число отброшенных имён
Requirement: Учёт частично разобранной доставки
Система SHALL отличать доставку, разобранную целиком, от доставки, в теле
которой остались непокрытые разбором секции. Доставка с непустым списком
непокрытых ключей MUST получать статус partial, а не parsed.
Статусы разбора:
pending этим разбором ещё не смотрели — или смотрели, но работа не сделана
по обстоятельствам (см. ниже)
parsed разобрано всё, что в теле было
partial разобрано покрытое; в теле остались непокрытые секции
failed разобрать не удалось, точек нет
Источник истины — список непокрытых ключей; статус производен от него и от
факта отказа, в порядке failed → partial → parsed. Приоритет назван явно,
чтобы читатели (ретеншен, статистика) спрашивали статус, а не сравнивали список
со строкой.
Отказ обстоятельств статуса не меняет вовсе. Отмена работы снаружи и
занятость базы дольше повторов транзакции означают «не сделано», а не «не
выходит»: доставка остаётся pending и будет свёрнута снова. Правило появилось
не из аккуратности — фоновая свёртка failed не подбирает никогда, и без этого
различения занятость базы (а с фоновой свёрткой конкуренция за неё штатная)
выводила бы доставку из очереди навсегда. Различение живёт в одном месте:
тот, кто пишет исход, и тот, кто классифицирует его в счётчики, спрашивают один
предикат.
Дедлайн самой свёртки к обстоятельствам MUST NOT относиться: доставка, не уложившаяся в бюджет, не уложится в него и в следующий раз, а бесконечный повтор заведомо безнадёжного — это очередь, которая не движется.
Отказы, случившиеся до чтения тела (учётной записи нет, соседний запрос не
прошёл), и отказ самой записи исхода статуса не меняют по другой причине —
записать его нечем. Доставка остаётся pending, что честно: этим разбором её не
досмотрели.
Список непокрытых ключей SHALL сохраняться рядом с доставкой — именами ключей,
без содержимого секций. Он же ответ на вопрос «что останется потерянным, если
тело удалить»: для stateOfMind доставки HAE единственный источник, в экспорте
Apple его нет (находка 46). Поэтому список MUST сохраняться и при отказе
разбора, если разбор успел его собрать: failed с непустым списком — законное
состояние.
Запись списка MUST замещать прежнее значение целиком, включая замещение пустым:
иначе доставка, все секции которой стали покрытыми, осталась бы partial
навсегда.
Список — снимок покрытия на момент свёртки. Задача, которая начинает
разбирать секцию, тем же изменением SHALL переводить partial-строки с этим
ключом в pending; ретеншену позволено смотреть на partial только при
соблюдении этого правила.
Статусы, поставленные разбором, который частичного исхода не различал, доверия
не заслуживают: под parsed у них лежат и полностью разобранные доставки, и
доставки без метрик вовсе. Такие строки MUST переводиться в pending — «этим
разбором ещё не смотрели». Число точек у них до пересвёртки остаётся прежним: оно
производно от объектов витрины, которые никуда не делись.
Scenario: Доставка с непокрытой секцией отмечается частичной
- WHEN разбор доставки вернул непустой список непокрытых ключей
- THEN
parse_statusдоставки равенpartial - AND список непокрытых ключей сохранён вместе с доставкой
- AND точки покрытой секции сохранены как обычно
Scenario: Доставка без непокрытых секций остаётся parsed
- WHEN разбор доставки не дал непокрытых ключей
- THEN
parse_statusравенparsed - AND сохранённый список непокрытых ключей пуст
Scenario: Отказ разбора сильнее частичности
- WHEN разбор доставки завершился ошибкой в самом разборе или в записи точек
- THEN
parse_statusравенfailed - AND список непокрытых ключей сохранён, если разбор успел его собрать
Scenario: Занятая база доставку из очереди не выводит
- WHEN разбор не состоялся из-за занятости базы или отмены работы снаружи
- THEN
parse_statusостаётсяpending
Scenario: Пересвёртка после того, как секция стала покрытой
- WHEN доставка со статусом
partialсворачивается повторно разбором, который эту секцию покрывает - 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 отпечаток отражает одно состояние базы, а не смесь снимков