Files
healthlog/openspec/specs/storage/spec.md
T
av 8331328134 Дозакрыты находки ревью по слиянию сущностей
- Правило покрытия получило второй разряд (условный, как у точек), запрет
  вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и
  ряд из null больше не затирают маршрут. Победитель внутри доставки стал
  функцией множества версий — общим помощником с точками, — а провенанс
  поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину
  к прежнему содержимому.
- Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной
  записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма
  считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает
  на старте; текст ошибки разбора не несёт значений из тела.
- Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты:
  безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя
  был квадратичен по числу присланных версий одного ключа.
2026-08-02 16:38:18 +03:00

85 KiB
Raw Blame History

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   разобрать не удалось, точек нет

Источник истины — список непокрытых ключей; статус производен от него и от факта отказа, в порядке failedpartialparsed. Приоритет назван явно, чтобы читатели (ретеншен, статистика) спрашивали статус, а не сравнивали список со строкой.

Отказ обстоятельств статуса не меняет вовсе. Отмена работы снаружи и занятость базы дольше повторов транзакции означают «не сделано», а не «не выходит»: доставка остаётся 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 считаться один раз на версию и до входа в транзакцию, а хеш SHALL браться из уже посчитанной формы. Внутри транзакции канонизации приехавших версий быть MUST NOT: транзакция открывается immediate, то есть блокирует запись, и повторяется до пяти раз при занятости базы — измерено, что тело 40 МиБ даёт пик кучи 768 МиБ, а тело 63 МиБ удерживает блокировку 5.019 с при busy_timeout 5000, после чего конкурентная доставка исчерпывает повторы. Считать форму дважды (в хеше и в сравнении) система MUST NOT: это ровно та же работа над теми же байтами.

Остаточный предел называется вслух: разбор сохранённой версии остаётся внутри транзакции — её содержимое читается оттуда же и только когда хеш разошёлся. Значит удержание блокировки по-прежнему пропорционально размеру сохранённой сущности, и класс отказа «конкурентный приём исчерпал повторы → 500 по доставке, тело которой уже в архиве» этим требованием не закрывается, а лишь становится различимым в логе. Закрыть его может только предел на размер сущности вместе с потоковым расчётом — отдельная задача.

Scenario: Тренировка хранится одной строкой с маршрутом

  • WHEN приезжает тренировка с маршрутом
  • THEN она хранится одной строкой, адресуемой своим id
  • AND маршрут и внутренние ряды лежат в её содержимом дословно

Scenario: Ряд пульса тренировки не попадает в метрику

  • WHEN тренировка несёт heartRateData
  • THEN объектов метрики heart_rate эта доставка не создаёт

Scenario: Записи разных родов с одинаковым id не сталкиваются

  • WHEN две записи разных родов приезжают с одним и тем же id
  • THEN в хранилище лежат обе

Scenario: Повторная присылка той же тренировки не пишет в базу

  • WHEN приезжает тренировка, содержимое которой совпадает с сохранённым, и её доставка стоит в журнале не позже сохранённой
  • 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 проверяться четырьмя условиями, все — по верхнему уровню содержимого:

  1. каждый ключ сохранённой с непустым значением есть у приехавшей и тоже непуст;
  2. при равенстве множеств содержательных ключей — каждый ключ сохранённой, включая пустые, есть у приехавшей. Тот же второй разряд записан для точек, и с тем же условием: иначе ключ с пустым значением исчезает по жребию тай-брейка. Безусловным он быть MUST NOT — проверено оракулом: версия с пустым ключом и без маршрута оказывалась несравнимой с законным досчётом, у которого маршрут приехал, а этого ключа нет, и маршрут не доезжал НИКОГДА;
  3. форма значения не вырождается: где у сохранённой объект, у приехавшей MUST быть объект; где массив — массив. Версия, подменившая объект или массив скаляром, покрывающей быть MUST NOT — иначе «скелет» из скаляров и null-ов той же длины признаётся равным настоящей тренировке и выигрывает тай-брейк журнала;
  4. верхнеуровневый массив не теряет ни длины, ни содержательных элементов: усечённый маршрут (три точки вместо 593) ключа не теряет, а маршрут из [null,null,null] не теряет и длины — притом что маршрут это 95% содержимого тренировки. Досчёт ряды удлиняет, поэтому и укорачивание, и опустошение элементов — законные признаки «приехало меньше».

Содержательность элемента ряда SHALL определяться той же пустотой, что и содержательность поля точки: null, пустая строка, ноль в любой записи, пустой объект, пустой массив; false содержателен. Второй словарь пустоты в проекте завёл бы два ответа на один вопрос. Цена этого выбора называется вслух: ряд из настоящих нулей ([0,0,0]) считается лишённым содержания, поэтому версия с таким рядом сохранённую не заместит. Ошибка направлена в безопасную сторону — правило удерживает, а не затирает, — и событие видно счётчиком; наблюдённые ряды HAE состоят из объектов, а не из чисел.

Условия 3 и 4 применяются к ключам, содержательным у сохранённой версии. Ключ, содержания не несущий, проверяется только на присутствие (условие 2): формы у пустоты нет, и требовать её сохранения означало бы отличать [] от 0 там, где ни то, ни другое ничего не несёт.

Предел правила называется вслух и не закрывается: сокращение внутри элемента ряда (точка маршрута без altitude при непустом элементе и той же длине) не ловится ничем, кроме сверки с телом в архиве.

Содержимое сущности, не разбирающееся как объект JSON, SHALL давать пустые множества ключей — то же правило, что для точки: такая версия проигрывает любой версии с содержанием и не загрязняет наблюдение о несравнимых наборах.

Единственная причина повторной присылки — доезжающий маршрут, то есть рост: обратного за 44 доставленные копии не случилось ни разу. Но восстановление требует пересборки всего журнала, поэтому событие делается наблюдаемым, а не необратимым.

Тай-брейк при равных наборах — позиция доставки в журнале (received_at, id), а не порядок свёртки. «Побеждает приехавшая» было бы функцией порядка свёртки, а он порядку журнала не равен: воркер сворачивает в порядке журнала только среди видимых ему доставок и абсолютного порядка при конкурентных приёмах не обещает. Доставка с более ранней меткой, свёрнутая позже, вернула бы витрину к недосчитанной версии, и пересборка разошлась бы с живым приёмом молча, в содержимом тренировки. Позиция журнала снимает это: исход зависит от журнала, а не от того, кто раньше добрался до базы.

Ровно поэтому провенанс сущности SHALL обновляться и тогда, когда хеш совпал: сохранённая позиция журнала участвует в тай-брейке пункта 4, и если в ней осталась первая свёрнутая копия вместо победителя журнала, отложенная доставка вернёт витрину к прежнему содержимому — то есть живая витрина разойдётся с пересборкой. Обновление MUST касаться только провенанса; содержимое при совпавшем хеше не переписывается, счётчик записанных сущностей не растёт (он считает содержимое витрины, и его сравнимость с прежними замерами важнее учёта обновления), и метка изменения содержимого не двигается тоже: иначе она стала бы меткой касания строки и дребезжала бы двадцать шесть раз на неизменившейся тренировке, а потребитель запроса «что изменилось с момента X» получил бы шум, неотличимый от настоящего досчёта. Провенанс несёт собственную метку — времени приёма своей доставки, — и для тай-брейка её достаточно.

Обновление провенанса SHALL быть идемпотентным: равные позиции журнала (та же доставка, свёрнутая повторно) ничего не меняют.

Слово «провенанс» у сущности и у часового объекта означает разное, и это называется вслух: у объекта хранится доставка, создавшая его, и она не поднимается никогда; у сущности — доставка, чья версия лежит сейчас, и она поднимается до максимума по журналу среди версий с этим содержимым. Причина в том, что у объекта нет замещения версии целиком, а у сущности только оно и есть.

Чтение сохранённой версии, сравнение и запись результата SHALL идти одной транзакцией: хеш и провенанс, на которых держится весь тай-брейк, читаются там же, где пишется исход. Оптимистичное чтение до транзакции допустимо только с перепроверкой обоих внутри — иначе две конкурентные свёртки одной сущности прочитают одну и ту же старую позицию, обе решат «я позже», и победит та, что закоммитила последней: исход снова станет функцией порядка коммитов, а не журнала, причём молча.

Отличие от точки здесь содержательное: у точки на одних координатах законно встречаются два разных измерения, и предпочитать позднее нет оснований — там исход решает порядок канонических форм. У сущности id — идентичность одного объекта HealthKit, и вторая версия есть тот же объект, пересчитанный источником; тай-брейк по канонической форме заморозил бы тренировку на произвольной из версий навсегда, вместе с недосчитанной энергией.

Версии одного ключа внутри одной доставки позициями не различаются, и победитель среди них SHALL быть функцией множества версий, а не порядка элементов массива: сперва отбрасываются строго покрытые кем-то из остальных, среди оставшихся берётся минимум канонической формы. «Строго покрыта» означает «покрыта другой версией и сама её не покрывает»: покрытие — предпорядок, две версии могут покрывать друг друга взаимно, и отбрасывание всего покрытого опустошило бы множество, потеряв обе. Порядок при этом обязан быть тотальным до конца: при совпавших канонических формах решает минимум исходных байтов — иначе победителем оказывается тот, кто стоял в массиве раньше, а порядок ключей в JSON от HAE нестабилен, и в хранилище легли бы разные байты при одинаковом содержимом. Попарная свёртка здесь неверна ровно так же, как она была неверна для точек: покрытие — частичный порядок, тай-брейк — тотальный, и вместе они дают нетранзитивное отношение победы, при котором [A,B,C] и [B,C,A] дают разных победителей, а порядок элементов в JSON-массиве нестабилен. Сворачиваться между собой такие версии SHALL до сравнения с сохранённой.

Факт «в одном теле приехали две версии одного ключа с разным содержанием» SHALL считаться симметрично и тоже быть функцией множества: считаются кандидаты, чья каноническая форма отличается от формы победителя. Счётчик этот SHALL быть ОТДЕЛЬНЫМ от счётчика удержаний: две версии в одном теле содержания не теряют — победитель ложится в витрину целиком, — и одно число на два события отвечало бы ни на одно. На счётчик удержаний опирается единственный контроль того, что правило покрытия не стало слишком строгим; примесь делает его неотличимым от шума.

Версии с совпавшей канонической формой SHALL схлопываться ДО выбора победителя. Выбор квадратичен по числу кандидатов, а их число приходит из чужого тела; без схлопывания тело в пределах приёма занимает свёртку на часы. Отбор SHALL видеть отмену: иначе дедлайн свёртки, заведённый ровно против зависшей работы, не значит ничего. Побайтовое различие при совпавшей канонической форме событием MUST NOT считаться — порядок ключей в JSON от HAE нестабилен и дребезг последнего разряда double тоже, так что счётчик по байтам срабатывал бы на измеренной норме потока. Различие содержимого при совпадающих множествах ключей и длинах массивов считаться SHALL: сегодня ровно этот случай даёт ноль и молчащий счётчик.

Поля версий MUST NOT объединяться: несравнимые наборы (приехавшая принесла новые ключи и потеряла старые) разрешаются в пользу сохранённой и считаются тем же счётчиком. Объединение отвергнуто там же и по той же причине, что для точек: на живом потоке событие не наступало, и вместо реализации заведено наблюдение.

Исход SHALL быть функцией журнала в его порядке. Остаточный предел называется вслух: сравнение сохранённой с приехавшей попарно — в витрине лежит победитель прошлых слияний, а не все кандидаты истории, — поэтому при несравнимых наборах (пункт 5) исход зависит от порядка проигрывания. Тот же предел есть у часового объекта; пункты 1–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 та же тренировка приезжает повторно с route той же длины, все элементы которого пусты (null либо пустой объект)
  • THEN в хранилище остаётся сохранённая версия с координатами маршрута
  • AND факт учитывается тем же счётчиком

Scenario: Скелет из скаляров сохранённую тренировку не затирает

  • WHEN та же тренировка приезжает повторно, где каждый вложенный объект заменён числом, а каждый массив — массивом той же длины из null
  • THEN в хранилище остаётся сохранённая версия
  • AND факт учитывается тем же счётчиком

Scenario: Ключ с пустым значением не исчезает по жребию

  • WHEN та же тренировка приезжает повторно без ключа, значение которого у сохранённой было пустым, при совпадающих содержательных ключах
  • THEN в хранилище остаётся сохранённая версия
  • AND факт учитывается тем же счётчиком удержаний

Scenario: Пустой ключ не запирает законный досчёт

  • WHEN у сохранённой версии есть ключ с пустым значением, а приехавшая его не несёт, но приносит содержательный ключ, которого у сохранённой не было
  • THEN приехавшая замещает сохранённую
  • AND счётчик удержаний не растёт

Scenario: Две версии одной сущности в одном теле

  • WHEN тело содержит два элемента секции с одним id
  • THEN исход не зависит от их порядка в массиве
  • AND счётчик различающихся версий тоже не зависит от их порядка

Scenario: Три версии одной сущности в одном теле

  • WHEN тело содержит три элемента секции с одним id, из которых один покрывает второй, а третий несравним с обоими
  • THEN победитель одинаков при любой перестановке этих трёх элементов

Scenario: Две версии разного содержания при равной длине массивов

  • WHEN тело содержит два элемента секции с одним id, содержимое которых различается, но множества ключей и длины верхнеуровневых массивов совпадают
  • THEN факт учитывается счётчиком различающихся версий

Scenario: Разные байты при совпавшей канонической форме событием не считаются

  • WHEN тело содержит два элемента секции с одним id, различающихся только порядком ключей либо записью числа
  • THEN счётчик различающихся версий не растёт
  • AND в хранилище лежат одни и те же байты при любой перестановке элементов

Scenario: Повторная присылка обновляет провенанс

  • WHEN та же сущность приезжает повторно с тем же содержимым доставкой, стоящей в журнале позже сохранённой
  • THEN содержимое не переписывается
  • AND провенанс сущности указывает на более позднюю доставку

Scenario: Отложенная доставка не возвращает витрину к прежнему содержимому

  • WHEN журнал несёт содержимое A, затем B, затем снова A, и доставка с B свёрнута последней
  • THEN содержимое сущности и отпечаток витрины совпадают со свёрткой того же журнала в его порядке

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 отпечаток отражает одно состояние базы, а не смесь снимков

Requirement: Открытие базы отказывает при схеме из будущего

Открытие витрины SHALL сверять версию схемы базы с версией, вшитой в бинарь, до наката миграций. Версия базы выше версии бинаря MUST быть отказом с указанием обеих, а не поводом мигрировать: прецедент уже записан для открытия только на чтение — «расхождение версий — отказ, а не повод мигрировать».

Без этого откат бинаря проходит молча: старый бинарь поверх новой схемы стартует успешно, незнакомые секции игнорирует и доставки за окно отката помечает разобранными — то есть ничто не намекает, что для этого окна нужна пересборка. Класс «молчание», и цена его растёт вместе с ретеншеном: после удаления тел окно становится невосстановимым.

Асимметрия относится только к открытию с накатом миграций: там версия базы ниже версии бинаря отказом быть MUST NOT — ради этого случая миграции и существуют. Открытие только на чтение сохраняет строгое равенство версий, как уже нормировано пересборкой: утилита, которой достаточно прочитать учёт, на базе старее бинаря читала бы колонки, которых там ещё нет. Ослабление этого отказа настоящим требованием запрещено.

В одно место SHALL выноситься чтение версии, а не сравнение: сравнивают эти два способа открытия по-разному, а читают одинаково. Отсутствие журнала миграций (новая база) SHALL означать версию 0, и распознаваться это MUST по структуре базы, а не по тексту ошибки драйвера — сообщения драйвера контрактом не являются, и это уже записанное правило проекта.

Читать версию система SHALL средствами того же инструмента миграций, которым их накатывает, если он это умеет: имя таблицы учёта, имя колонки и правило «максимум = текущая версия» принадлежат ему, и рукописная копия его приватной схемы разошлась бы при обновлении зависимости — причём не отказом, а тем, что страж перестал бы ловить. Если цена такого чтения неприемлема (например, оно требует записи на соединении только для чтения), копия допустима, но SHALL жить одной функцией с названной вслух причиной.

Эксплуатационная цена отказа называется вслух, потому что она реальна: сервис не поднимется, а телефон шлёт непрерывно и молча, и доставка, не попавшая в архив, в журнал не попадает вовсе. Выбор сделан так потому, что откат бинаря — действие оператора, который в этот момент рядом и видит отказ сразу, а дыры плотных метрик закрывают широкий и глубокий проходы синхронизации. Не закрывается ими stateOfMind: у него доставки HAE единственный источник, и окно простоя для него — потеря без возврата. Молчаливый старт при этом стоит дороже: он портит витрину за всё окно отката, и узнать об этом неоткуда.

Scenario: Старый бинарь не открывает базу из будущего

  • WHEN в журнале миграций базы стоит версия выше последней, вшитой в бинарь
  • THEN открытие завершается отказом с указанием обеих версий
  • AND миграции не накатываются

Scenario: Новая база открывается и мигрирует

  • WHEN базы ещё нет либо журнал миграций пуст
  • THEN открытие проходит и накатывает миграции до версии бинаря

Scenario: Открытие только на чтение остаётся строгим

  • WHEN версия схемы базы ниже последней, вшитой в бинарь, и база открывается только на чтение
  • THEN открытие завершается отказом с указанием обеих версий

Requirement: Пропущенные сущности видны в учётной записи доставки

Учётная запись доставки SHALL нести число сущностей, которые разбор пропустил: без id, с непомерно длинным id, с неразбираемой меткой времени или не разобравшихся как объект.

Причина не в отчётности. Ретеншен сырого архива решает «что потеряется, если тело удалить», по базе, и сегодня получает ответ «терять нечего» ровно там, где потеряна тренировка с маршрутом: сущность в витрину не попала, список непокрытых секций пуст, статус parsed. Лог здесь не годится — он ротируется, а решение об удалении тела необратимо.

Число SHALL замещаться целиком при каждой свёртке доставки, включая замещение нулём: иначе доставка, пропуски которой исчезли вместе с поумневшим разбором, осталась бы помеченной навсегда. Записываться оно SHALL в обоих исходах свёртки — и при успехе, и при отказе, если разбор успел досчитать, — тем же правилом, каким уже записывается список непокрытых секций.

«Не измерялось» SHALL быть отличимо от нуля, и на пути отказа тоже. Разбор, вернувший ошибку, отдаёт нулевые счётчики по построению, а не по измерению; записать этот ноль значило бы объявить проверенной доставку, содержимое которой никто не смотрел. Число SHALL записываться только когда разбор досчитал; во всех прочих исходах колонка MUST оставаться нетронутой — той же идиомой, какой уже сохраняется выведенный слой. Доставки, свёрнутые разбором, который пропусков не считал, значения не имеют, и подстановка нуля объявила бы их проверенными: ретеншен получил бы то самое ложное «терять нечего», ради которого счётчик и заводится, — только теперь с видом измерения. Поэтому колонка допускает отсутствие значения, миграция его не подставляет, а читатель, принимающий по счётчику необратимое решение, SHALL трактовать отсутствие как «не удалять». Замер на живом архиве (118 тел) даёт ноль пропусков всех классов, то есть исторический корпус ничего не потерял, — но «ничего не потерял по замеру» и «проверено этим разбором» это разные утверждения, и колонка обязана их различать.

Счётчик — производное от разбора поле: пересборка витрины SHALL начинать его пустым и переносить из журнала MUST NOT, иначе свежая витрина унаследует измерение прежнего разбора.

Статус разбора от пропуска сущности меняться MUST NOT: partial определён списком непокрытых секций, и второй источник истины для него завёл бы ровно то расхождение читателей, которое учёт частичного разбора запрещает явно.

Scenario: Пропущенная сущность видна в учёте доставки

  • WHEN тело несёт покрытую секцию, один элемент которой не разобрался
  • THEN число пропущенных сущностей у доставки больше нуля
  • AND соседние сущности той же секции сохранены

Scenario: Пересвёртка без пропусков обнуляет счётчик

  • WHEN доставка с ненулевым числом пропущенных сущностей сворачивается повторно разбором, который эти элементы понимает
  • THEN число пропущенных сущностей у доставки равно нулю

Scenario: Доставка, свёрнутая до появления счётчика, отличима от нулевой

  • WHEN доставка была свёрнута разбором, который пропусков не считал, и с тех пор не пересворачивалась
  • THEN её число пропущенных сущностей отсутствует, а не равно нулю