Files
healthlog/openspec/specs/storage/spec.md
T
av 63bffe2865 Приём отвечает 200 до свёртки, свёртку ведёт фоновый воркер
- Очередью служит сама таблица: доставка ждёт свёртки в статусе `pending`,
  канал несёт только бит «есть работа». Переполнять нечего, падение процесса
  очередь не теряет, а подбор `pending` при старте — обычный проход воркера, а
  не отдельный код. Классификация исхода общая с пересборкой журнала.
- Исход разбора начал отражать доставку, а не обстоятельства: отмена и
  занятость базы статус не меняют (иначе конкуренция за базу выводила бы
  доставку из очереди навсегда), паника свёртки больше не валит процесс, а
  учёт доставки идёт через транзакцию с повторами.
- Длинный бюджет ответа выдан маршруту приёма, а не всему серверу:
  `write_timeout` в Go покрывает и чтение тела, и общий подъём снял бы защиту с
  остальных маршрутов.
2026-08-02 11:01:42 +03:00

430 lines
33 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 оставлять
**более полную** точку — ту, чьё множество ключей с непустым значением является
**строгим надмножеством** множества другой, — а не последнюю пришедшую. Иначе
бедная доставка стирает у богатой поля, которых сама не несёт.
Полнота 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`. Приоритет назван явно,
чтобы читатели (ретеншен, статистика) спрашивали статус, а не сравнивали список
со строкой.
**Отказ обстоятельств статуса не меняет вовсе.** Отмена работы снаружи и
занятость базы дольше повторов транзакции означают «не сделано», а не «не
выходит»: доставка остаётся `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** сохранённый список непокрытых ключей пуст