Files
healthlog/openspec/specs/storage/spec.md
T
av 7a7594e3e7 полнота точки — множество ключей, победитель — функция множества точек
- отношение победы было нетранзитивным: полнота (частичный порядок) плюс
  тай-брейк (тотальный) в попарной свёртке давали цикл, из-за которого одна
  и та же доставка меняла содержимое объекта при каждой пересборке
- надмножество побеждает только при совпадении значений общих содержательных
  ключей: иначе точка без единого измерения вытесняла измерение
- Less стал тотальным, isEmpty не материализует значение, имя метрики в
  координате столкновения обрезается, отпечаток витрины включает units и sealed
- на живом архиве строгий no-op: 1737 объектов, содержимое совпало побайтово
2026-08-01 21:05:43 +03:00

24 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.

Scenario: Разбор доставки логируется без значений

  • WHEN доставка разобрана
  • THEN запись лога содержит счётчики (метрик, точек, объектов) и идентификатор доставки
  • AND не содержит ни значений точек, ни имён устройств