Files
healthlog/openspec/specs/storage/spec.md
T
av 37413bb551 change razbor-metrik-v-obekty заархивирован
Дельты влиты в openspec/specs (parsing, storage), задача убрана из беклога,
план отражает сделанную часть шага 3.

Не закрыт один пункт: живая доставка с телефона не разобрана — поток молчит
с 17:13, пауза началась до перезапуска сервиса.
2026-08-01 19:03:46 +03:00

216 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 оставлять
**более полную** точку — ту, у которой больше значащих полей, — а не последнюю
пришедшую. Иначе бедная доставка стирает у богатой поля, которых сама не несёт:
0.66% координат различаются именно набором полей при одинаковом значении.
Если полнота равна, а значения различаются, исход MUST быть детерминированным
и не зависеть от порядка, в котором доставки дошли до хранилища: свёртка по
журналу обязана давать то же состояние, что приём в реальном времени.
Сравнение по `received_at` для этого не годится: у сохранённой точки нет
провенанса — ни времени приёма, ни идентификатора доставки, — и сравнивать
не с чем. Детерминизм обеспечивается свойством самих значений (например,
порядком канонических форм), а не порядком событий.
#### Scenario: Бедная точка не стирает поля богатой
- **WHEN** сохранена точка с `Avg`, `Min`, `Max` и `context`
- **AND** по тем же координатам приезжает точка только с `Avg`, `Min` и `Max`
- **THEN** сохранённая точка остаётся с `context`
#### Scenario: Одинаково полные точки с разными значениями
- **WHEN** по одним координатам приходят две одинаково полные точки с разными
значениями
- **THEN** исход определяется детерминированно и не зависит от порядка
воспроизведения доставок
Столкновением 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** не содержит ни значений точек, ни имён устройств