- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`; миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки с этими ключами - сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА — «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина расходилась бы с пересборкой молча - отпечаток витрины покрывает тренировки и записи и снимается одним снимком базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
711 lines
57 KiB
Markdown
711 lines
57 KiB
Markdown
# 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 разобрать не удалось, точек нет
|
||
```
|
||
|
||
Источник истины — список непокрытых ключей; статус производен от него и от
|
||
факта отказа, в порядке `failed` → `partial` → `parsed`. Приоритет назван явно,
|
||
чтобы читатели (ретеншен, статистика) спрашивали статус, а не сравнивали список
|
||
со строкой.
|
||
|
||
**Отказ обстоятельств статуса не меняет вовсе.** Отмена работы снаружи и
|
||
занятость базы дольше повторов транзакции означают «не сделано», а не «не
|
||
выходит»: доставка остаётся `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 считаться один раз на
|
||
доставку, а не на каждой попытке повтора транзакции при занятости базы:
|
||
канонизация материализует значение целиком, и повтор умножал бы пик кучи.
|
||
|
||
#### Scenario: Тренировка хранится одной строкой с маршрутом
|
||
|
||
- **WHEN** приезжает тренировка с маршрутом
|
||
- **THEN** она хранится одной строкой, адресуемой своим `id`
|
||
- **AND** маршрут и внутренние ряды лежат в её содержимом дословно
|
||
|
||
#### Scenario: Ряд пульса тренировки не попадает в метрику
|
||
|
||
- **WHEN** тренировка несёт `heartRateData`
|
||
- **THEN** объектов метрики `heart_rate` эта доставка не создаёт
|
||
|
||
#### Scenario: Записи разных родов с одинаковым id не сталкиваются
|
||
|
||
- **WHEN** две записи разных родов приезжают с одним и тем же `id`
|
||
- **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 сравниваться **длина верхнеуровневых
|
||
массивов**: усечённый маршрут (три точки вместо 593) ключа не теряет, а теряет
|
||
95% содержимого тренировки. Досчёт ряды удлиняет, поэтому укорачивание —
|
||
законный признак «приехало меньше». Предел правила называется вслух: сокращение
|
||
**внутри** элемента ряда (точка маршрута без `altitude`) не ловится ничем, кроме
|
||
сверки с телом в архиве.
|
||
|
||
Единственная причина повторной присылки — доезжающий маршрут, то есть рост:
|
||
обратного за 44 доставленные копии не случилось ни разу. Но восстановление
|
||
требует пересборки всего журнала, поэтому событие делается наблюдаемым, а не
|
||
необратимым.
|
||
|
||
**Тай-брейк при равных наборах — позиция доставки в журнале `(received_at, id)`,
|
||
а не порядок свёртки.** «Побеждает приехавшая» было бы функцией порядка
|
||
свёртки, а он порядку журнала не равен: воркер сворачивает в порядке журнала
|
||
только среди видимых ему доставок и абсолютного порядка при конкурентных
|
||
приёмах не обещает. Доставка с более ранней меткой, свёрнутая позже, вернула бы
|
||
витрину к недосчитанной версии, и пересборка разошлась бы с живым приёмом молча,
|
||
в содержимом тренировки. Позиция журнала снимает это: исход зависит от журнала,
|
||
а не от того, кто раньше добрался до базы.
|
||
|
||
Отличие от точки здесь содержательное: у точки на одних координатах законно
|
||
встречаются два разных измерения, и предпочитать позднее нет оснований — там
|
||
исход решает порядок канонических форм. У сущности `id` — идентичность одного
|
||
объекта HealthKit, и вторая версия есть тот же объект, пересчитанный источником;
|
||
тай-брейк по канонической форме заморозил бы тренировку на произвольной из
|
||
версий навсегда, вместе с недосчитанной энергией.
|
||
|
||
Две версии одного ключа **внутри одной доставки** позициями не различаются и
|
||
SHALL разрешаться минимумом канонической формы — включая случай несравнимых
|
||
наборов. Внутри доставки «сохранённой» версии не существует, есть только
|
||
порядок элементов в JSON-массиве, а он нестабилен: правило «остаётся первая
|
||
встреченная» сделало бы исход функцией порядка на проводе. Сворачиваться между
|
||
собой такие версии SHALL до сравнения с сохранённой, а факт «в одном теле
|
||
приехали две версии одного ключа с разным содержанием» SHALL считаться
|
||
**симметрично**: счётчик, зависящий от порядка элементов, наблюдал бы событие
|
||
через раз.
|
||
|
||
Поля версий MUST NOT объединяться: несравнимые наборы (приехавшая принесла
|
||
новые ключи и потеряла старые) разрешаются в пользу сохранённой и считаются
|
||
тем же счётчиком. Объединение отвергнуто там же и по той же причине, что для
|
||
точек: на живом потоке событие не наступало, и вместо реализации заведено
|
||
наблюдение.
|
||
|
||
Исход SHALL быть функцией журнала в его порядке. Остаточный предел называется
|
||
вслух: слияние попарное — сохранённая против приехавшей, — поэтому при
|
||
несравнимых наборах (пункт 5) исход зависит от порядка проигрывания. Тот же
|
||
предел есть у часового объекта, где хранится победитель прошлых слияний, а не
|
||
все кандидаты истории; пункты 2–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** тело содержит два элемента секции с одним `id`
|
||
- **THEN** исход не зависит от их порядка в массиве
|
||
- **AND** счётчик различающихся версий тоже не зависит от их порядка
|
||
|
||
#### 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** отпечаток отражает одно состояние базы, а не смесь снимков
|
||
|