Files
healthlog/openspec/changes/razbor-metrik-v-obekty/specs/storage/spec.md
T
av 3e93dd95b4 ключ точки — интервал одной формы, класс «эпизодных метрик» убран
Перепись по 22 метрикам с интервалами опровергла признак из первой редакции:
start всегда равен date, интервалы несёт не только сон, обе формы точки не
смешиваются внутри метрики одной доставки, а разные интервалы под одной меткой
всегда несут разное содержимое. Значит ключ единый — метрика + слой + начало +
конец, у измерения вырожденный, без ветвления по классу.
2026-08-01 17:15:49 +03:00

12 KiB
Raw Blame History

ADDED 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 оставлять более полную точку — ту, у которой больше значащих полей, — а не последнюю пришедшую. Иначе бедная доставка стирает у богатой поля, которых сама не несёт: 0.66% координат различаются именно набором полей при одинаковом значении.

Если полнота равна, а значения различаются, исход MUST быть детерминированным и не зависеть от порядка, в котором доставки дошли до хранилища: свёртка по журналу обязана давать то же состояние, что приём в реальном времени.

Сравнение по received_at для этого не годится: у сохранённой точки нет провенанса — ни времени приёма, ни идентификатора доставки, — и сравнивать не с чем. Детерминизм обеспечивается свойством самих значений (например, порядком канонических форм), а не порядком событий.

Scenario: Бедная точка не стирает поля богатой

  • WHEN сохранена точка с Avg, Min, Max и context
  • AND по тем же координатам приезжает точка только с Avg, Min и Max
  • THEN сохранённая точка остаётся с context

Scenario: Одинаково полные точки с разными значениями

  • WHEN по одним координатам приходят две одинаково полные точки с разными значениями
  • THEN исход определяется детерминированно и не зависит от порядка воспроизведения доставок

Scenario: Столкновение с различием содержимого оставляет след

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

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

Requirement: Хранение часовыми объектами

Система SHALL хранить точки часовыми объектами с ключом метрика + слой + час (UTC). Содержимое объекта — сжатый gzip блоб; точки внутри упорядочены по времени.

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

Объект SHALL нести границы содержимого (первая и последняя метка) и идентификатор доставки, создавшей его. Первое нужно каталогу разрезов, чтобы не разжимать каждый блоб ради диапазона; второе — провенанс для разбора слияний.

Запись — чтение объекта, слияние точек, запись обратно. Точки из объекта MUST NOT удаляться.

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 не содержит ни значений точек, ни имён устройств