# 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`. Непокрытые секции называются в логе **именами ключей**: имя секции — это форма пакета, а не измерение. Содержимое секции в лог не попадает ни при каком уровне выше `DEBUG`. Имена идут структурным атрибутом, а не склейкой в текст сообщения: кодировщик экранирует управляющие символы, и имя из чужого тела не разрывает построчный разбор логов. Частичный разбор уровня записи не повышает: `partial` — установившееся состояние половины потока (48 доставок из 99), и постоянный `WARN` обесценил бы уровень. Повышает уровень другое — срабатывание границ списка: тело с сотнями секций или с именем длиннее предела на HAE не похоже вовсе. #### Scenario: Разбор доставки логируется без значений - **WHEN** доставка разобрана - **THEN** запись лога содержит счётчики (метрик, точек, объектов) и идентификатор доставки - **AND** не содержит ни значений точек, ни имён устройств #### Scenario: Непокрытые секции названы именами ключей - **WHEN** доставка содержит непокрытую секцию - **THEN** запись лога содержит имена непокрытых ключей отдельным атрибутом - **AND** не содержит ничего из содержимого этих секций - **AND** уровень записи из-за одной лишь частичности не повышается #### Scenario: Границы списка сработали - **WHEN** список непокрытых ключей усечён по числу имён или по длине имени - **THEN** запись лога имеет уровень `WARN` - **AND** содержит число отброшенных имён ### Requirement: Учёт частично разобранной доставки Система SHALL отличать доставку, разобранную целиком, от доставки, в теле которой остались непокрытые разбором секции. Доставка с непустым списком непокрытых ключей MUST получать статус `partial`, а не `parsed`. Статусы разбора: ``` pending этим разбором ещё не смотрели parsed разобрано всё, что в теле было partial разобрано покрытое; в теле остались непокрытые секции failed разобрать не удалось, точек нет ``` Источник истины — список непокрытых ключей; статус производен от него и от факта отказа, в порядке `failed` → `partial` → `parsed`. Приоритет назван явно, чтобы читатели (ретеншен, статистика) спрашивали статус, а не сравнивали список со строкой. Список непокрытых ключей 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** доставка со статусом `partial` сворачивается повторно разбором, который эту секцию уже покрывает - **THEN** её статус становится `parsed` - **AND** сохранённый список непокрытых ключей пуст #### Scenario: Строки прежнего разбора переводятся в неразобранные - **WHEN** база содержит доставки со статусом `parsed`, свёрнутые до появления частичного статуса - **THEN** после миграции их статус равен `pending` - **AND** тела остаются в архиве, а повторная свёртка даёт то же состояние