change razbor-metrik-v-obekty заархивирован
Дельты влиты в openspec/specs (parsing, storage), задача убрана из беклога, план отражает сделанную часть шага 3. Не закрыт один пункт: живая доставка с телефона не разобрана — поток молчит с 17:13, пауза началась до перезапуска сервиса.
This commit is contained in:
@@ -18,7 +18,6 @@
|
|||||||
- [Судьба доставки, у которой разобрана не вся секция data](nerazobrannye-sekcii-dostavki.md) — тело с одним stateOfMind помечается parsed, а ретеншен снесёт его как разобранное
|
- [Судьба доставки, у которой разобрана не вся секция data](nerazobrannye-sekcii-dostavki.md) — тело с одним stateOfMind помечается parsed, а ретеншен снесёт его как разобранное
|
||||||
|
|
||||||
## высокий
|
## высокий
|
||||||
- [Разбор метрик в часовые объекты](razbor-metrik-v-obekty.md) — Доставки копятся непрозрачными телами — точек в хранилище нет вовсе, всё остальное упирается в это
|
|
||||||
- [Тренировки и секции с собственными id](trenirovki-i-zapisi.md) — Тренировки с геотреком и состояние разума приходят, но не разбираются — без них не закрыть ни трекер, ни агента-медика
|
- [Тренировки и секции с собственными id](trenirovki-i-zapisi.md) — Тренировки с геотреком и состояние разума приходят, но не разбираются — без них не закрыть ни трекер, ни агента-медика
|
||||||
- [Пересборка хранилища из сырого архива](reindex-iz-arhiva.md) — Ошибка разбора без пересборки становится потерей данных — исправленный код не применится к уже разобранному
|
- [Пересборка хранилища из сырого архива](reindex-iz-arhiva.md) — Ошибка разбора без пересборки становится потерей данных — исправленный код не применится к уже разобранному
|
||||||
- [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое
|
- [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
# Разбор метрик в часовые объекты
|
|
||||||
|
|
||||||
**Приоритет:** высокий
|
|
||||||
|
|
||||||
Приём работает, но дальше архива данные не идут: 89 доставок лежат телами
|
|
||||||
`.json.gz`, а в SQLite только строки `delivery`. Всё остальное — каталог,
|
|
||||||
Read API, MCP — стоит на этой задаче.
|
|
||||||
|
|
||||||
Правила выведены на живом потоке и проверены, изобретать заново не нужно
|
|
||||||
(`docs/local-research.md`, находки 33, 35, 36, 38, 39, 41):
|
|
||||||
|
|
||||||
- ключ точки — координаты `метрика + слой + метка`, у эпизода — `метрика + слой
|
|
||||||
+ начало + конец` (находка 47); `source` в ключ не входит;
|
|
||||||
- слой выводится из выравнивания меток: плотная метрика (≥10 точек)
|
|
||||||
классифицируется сама, редкая наследует преобладающий слой доставки;
|
|
||||||
- при столкновении выигрывает более полная точка, а не последняя пришедшая
|
|
||||||
(0.66% координат различаются набором полей, а не значением);
|
|
||||||
- три формата времени: локальное со смещением, RFC 3339 Z, Unix-эпоха внутри
|
|
||||||
`heartbeatSeries`;
|
|
||||||
- `sleep_analysis` разводится на два имени — поэпизодное и суточную сводку.
|
|
||||||
|
|
||||||
Шаги:
|
|
||||||
- миграция `bucket` (`метрика + слой + час`, payload gzip-BLOB, `sealed`);
|
|
||||||
- разбор метрик, вывод слоя, канонизация с округлением до ~12 значащих цифр;
|
|
||||||
- слияние точек в объект read-modify-write, хеш объекта как детектор изменений;
|
|
||||||
- `docs/database.md` — ER-схема (её требует шаг гейта `er-schema`).
|
|
||||||
|
|
||||||
Готово, когда по существующим доставкам собирается хранилище, суммы по часовому
|
|
||||||
слою сходятся с проверкой из `tmp/research/`, а эпизодов сна выходит 174, а не
|
|
||||||
170 (находка 47).
|
|
||||||
|
|
||||||
Связано: `docs/architecture.md` → «Хранилище», план шаг 3.
|
|
||||||
|
|
||||||
+9
-5
@@ -8,13 +8,14 @@
|
|||||||
|
|
||||||
## Ближайшая цель
|
## Ближайшая цель
|
||||||
|
|
||||||
Приём работает, поток настоящих пакетов копится в сыром архиве. Дальше —
|
Метрики разбираются и ложатся в часовые объекты: тела перестали быть
|
||||||
разбор и хранилище (шаг 3): всё, что нужно, чтобы данные перестали быть
|
недифференцированной кучей. Дальше — доразобрать остаток шага 3
|
||||||
недифференцированной кучей тел запросов.
|
(тренировки и записи со своими `id`, `reindex`, словарь категориальных
|
||||||
|
значений) и разобрать блокеры, накопившиеся из ревью.
|
||||||
|
|
||||||
Разведка закончена: правило вывода слоя, модель идентичности и формы точки
|
Разведка закончена: правило вывода слоя, модель идентичности и формы точки
|
||||||
проверены на живом потоке, выводы — в
|
проверены на живом потоке, выводы — в
|
||||||
[local-research.md](local-research.md), 46 находок.
|
[local-research.md](local-research.md), 47 находок.
|
||||||
|
|
||||||
## Шаги
|
## Шаги
|
||||||
|
|
||||||
@@ -23,7 +24,10 @@
|
|||||||
- [x] **2. Приём без разбора.** `POST /api/v1/ingest`: лимит тела, gzip,
|
- [x] **2. Приём без разбора.** `POST /api/v1/ingest`: лимит тела, gzip,
|
||||||
запись тела в архив, строка в `delivery`. Разбора ещё нет.
|
запись тела в архив, строка в `delivery`. Разбора ещё нет.
|
||||||
← **подключаем телефон по локальной сети**
|
← **подключаем телефон по локальной сети**
|
||||||
- [ ] **3. Разбор и хранилище.** Миграции, часовые объекты метрик
|
- [~] **3. Разбор и хранилище.** Метрики — сделано (`bucket`, `internal/hae`,
|
||||||
|
`internal/canon`, `internal/fold`); остались тренировки и записи со
|
||||||
|
своими `id`, `reindex` и словарь категориальных значений. Миграции,
|
||||||
|
часовые объекты метрик
|
||||||
(`bucket`, ключ `метрика + слой + час`) + `workout`/`record` по своим
|
(`bucket`, ключ `метрика + слой + час`) + `workout`/`record` по своим
|
||||||
`id`. Вывод слоя из выравнивания меток; развод `sleep_analysis` на два
|
`id`. Вывод слоя из выравнивания меток; развод `sleep_analysis` на два
|
||||||
имени (находка 38). Канонизация с округлением чисел и хеш содержимого
|
имени (находка 38). Канонизация с округлением чисел и хеш содержимого
|
||||||
|
|||||||
+3
-2
@@ -52,9 +52,10 @@
|
|||||||
|
|
||||||
## 6. Сходимость на реальных данных
|
## 6. Сходимость на реальных данных
|
||||||
|
|
||||||
- [x] 6.1 Скрипт `tmp/research/verify_buckets.py`: прогоняет архив через разбор и сверяет суммы по часовому слою с проверкой из разведки
|
- [x] 6.1 Проверка сходимости — не отдельным скриптом, а `task verify:archive` поверх `internal/fold/replay_test.go`: одна реализация вместо двух, и она гоняет ровно продакшн-путь
|
||||||
- [x] 6.2 Прогон на всех накопленных доставках: расхождений по накопительным метрикам нет
|
- [x] 6.2 Прогон на всех накопленных доставках: расхождений по накопительным метрикам нет
|
||||||
- [ ] 6.3 `task gate` зелёный; `task restart` поднимает сервис, новая доставка с телефона разбирается
|
- [x] 6.3 `task gate` зелёный; `task restart` поднимает сервис
|
||||||
|
- [ ] 6.3a **Не закрыто:** живая доставка с телефона не разобрана — поток молчит с 17:13 (пауза началась ЗА ЧАС до перезапуска, то есть не из-за него). Сервис поднят и здоров, миграции на живой базе применились, схема проверена чтением. Пункт закрывается первой же пришедшей доставкой
|
||||||
|
|
||||||
## 7. Приёмочные критерии (рубрика ревью дизайна)
|
## 7. Приёмочные критерии (рубрика ревью дизайна)
|
||||||
|
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
# parsing Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
TBD - created by archiving change razbor-metrik-v-obekty. Update Purpose after archive.
|
||||||
|
## Requirements
|
||||||
|
### Requirement: Разбор секции метрик
|
||||||
|
|
||||||
|
Система SHALL разбирать секцию `data.metrics` тела доставки Health Auto Export
|
||||||
|
в точки. Точка несёт имя метрики, единицы, слой, метку времени и содержимое в
|
||||||
|
том виде, в каком его прислал HAE.
|
||||||
|
|
||||||
|
Хранимая форма точки — **исходные байты**, как они пришли в теле доставки.
|
||||||
|
Система MUST NOT пересобирать содержимое повторной сериализацией разобранных
|
||||||
|
значений: обход через `map[string]any` теряет литерал (`1.0` становится `1`,
|
||||||
|
целые больше 2^53 сдвигаются, невалидный UTF-8 заменяется на U+FFFD), и потеря
|
||||||
|
не видна тестам на фикстурах — они сравнивают разобранное с разобранным.
|
||||||
|
|
||||||
|
Отсюда же следует, что «служебных» полей у точки нет: отбрасывать нечего,
|
||||||
|
нормализованное время добавляется рядом с исходным содержимым, а не вместо.
|
||||||
|
|
||||||
|
#### Scenario: Метрика с точками разбирается в точки
|
||||||
|
|
||||||
|
- **WHEN** тело содержит `data.metrics[]` с непустым `data[]`
|
||||||
|
- **THEN** каждая точка с непустым `date` становится точкой хранилища
|
||||||
|
- **AND** её содержимое сохраняется исходными байтами, без пересборки
|
||||||
|
|
||||||
|
#### Scenario: Незнакомая метрика не ломает разбор
|
||||||
|
|
||||||
|
- **WHEN** приходит метрика с именем, которого разбор не знает
|
||||||
|
- **THEN** её точки разбираются наравне с остальными
|
||||||
|
- **AND** разбор не завершается ошибкой
|
||||||
|
|
||||||
|
#### Scenario: Точка без метки времени пропускается
|
||||||
|
|
||||||
|
- **WHEN** точка не содержит `date` либо `date` не разбирается ни одним из
|
||||||
|
поддерживаемых форматов
|
||||||
|
- **THEN** точка не попадает в хранилище
|
||||||
|
- **AND** факт учитывается в итоге разбора доставки
|
||||||
|
|
||||||
|
#### Scenario: Точка с нечитаемым концом интервала пропускается
|
||||||
|
|
||||||
|
- **WHEN** точка несёт `end`, который не разбирается
|
||||||
|
- **THEN** точка не попадает в хранилище
|
||||||
|
- **AND** факт учитывается отдельным счётчиком
|
||||||
|
|
||||||
|
Вырождать такую точку в мгновенную нельзя: две записи с общим началом получили
|
||||||
|
бы одну координату, и одна исчезла бы молча. Тело остаётся в архиве.
|
||||||
|
|
||||||
|
### Requirement: Вывод слоя гранулярности
|
||||||
|
|
||||||
|
Система SHALL выводить слой точки из **выравнивания меток времени**, а не из
|
||||||
|
заголовка доставки. Заголовок `automation-aggregation` непригоден: значение
|
||||||
|
`Default` соответствует трём разным режимам выгрузки.
|
||||||
|
|
||||||
|
Выводимые слои: `raw` (метка на произвольной секунде), `minute` (секунды
|
||||||
|
нулевые), `hour` (секунды и минуты нулевые). Слой `day` в перечисление входит,
|
||||||
|
но **не выводится** — он назначается схемам с фиксированной гранулярностью
|
||||||
|
(см. «Разделение схем под одним именем метрики»).
|
||||||
|
|
||||||
|
Выравнивание SHALL считаться по метке в **исходной зоне**, а не по метке в UTC.
|
||||||
|
HAE строит сетку по местному времени; ровный местный час в зоне со смещением на
|
||||||
|
половину (`+0530`, `+0545`, `+0930`) даёт UTC-метку на середине часа, и часовая
|
||||||
|
выгрузка целиком уехала бы в слой `minute` — где столкнулась бы с настоящей
|
||||||
|
минутной автоматизацией и завысила сумму минутного слоя вдвое.
|
||||||
|
|
||||||
|
Слоем метрики SHALL становиться **самое мелкое** выравнивание среди её меток, а
|
||||||
|
не преобладающее. Измерено: у плотных метрик выравнивания перемешаны
|
||||||
|
(`active_energy` — 1320 минутных меток и 21 часовая, `heart_rate` — 654
|
||||||
|
посекундных и 10 минутных), потому что метка ровно на часе одновременно
|
||||||
|
является и минутной. Метрика, у которой хоть одна метка стоит на середине часа,
|
||||||
|
часовой не является.
|
||||||
|
|
||||||
|
Разделение схем под одним именем выполняется **до** вывода слоя, и точки с
|
||||||
|
назначенным слоем в определении преобладающего слоя доставки не участвуют:
|
||||||
|
суточных сводок сна бывает больше порога плотности, и их полуночные метки
|
||||||
|
иначе назначили бы всей доставке слой `hour`.
|
||||||
|
|
||||||
|
Классификация MUST быть **по метрике внутри доставки**, а не по доставке
|
||||||
|
целиком: при перенастройке автоматизации приезжают смешанные доставки, и
|
||||||
|
отнесение такой доставки к одному слою складывает минутные точки с
|
||||||
|
посекундными.
|
||||||
|
|
||||||
|
#### Scenario: Плотная метрика классифицируется сама
|
||||||
|
|
||||||
|
- **WHEN** в доставке у метрики не меньше десяти точек с метками
|
||||||
|
- **THEN** слой определяется выравниванием её собственных меток
|
||||||
|
|
||||||
|
#### Scenario: Зона с получасовым смещением не делает часовую выгрузку минутной
|
||||||
|
|
||||||
|
- **WHEN** метки стоят на ровном местном часе, а смещение зоны равно `+0530`
|
||||||
|
- **THEN** слой метрики `hour`
|
||||||
|
|
||||||
|
#### Scenario: Одна метка на середине часа делает метрику минутной
|
||||||
|
|
||||||
|
- **WHEN** у плотной метрики десять меток стоят ровно на часе, а одна — на
|
||||||
|
середине часа
|
||||||
|
- **THEN** слой метрики `minute`, а не `hour`
|
||||||
|
|
||||||
|
#### Scenario: Редкая метрика наследует преобладающий слой
|
||||||
|
|
||||||
|
- **WHEN** в доставке у метрики меньше десяти точек
|
||||||
|
- **THEN** она получает самый мелкий слой среди плотных метрик этой доставки
|
||||||
|
- **AND** её собственное выравнивание во внимание не принимается
|
||||||
|
|
||||||
|
#### Scenario: Доставка, где всем метрикам слой назначен схемой
|
||||||
|
|
||||||
|
- **WHEN** в доставке нет метрик, которым слой надо выводить, — все точки
|
||||||
|
принадлежат схемам с фиксированной гранулярностью
|
||||||
|
- **THEN** доставка сохраняется, а слой доставки не выводится и не требуется
|
||||||
|
|
||||||
|
Иначе доставка автоматизации, настроенной только на сон, отвергалась бы
|
||||||
|
целиком — и необратимо: исход детерминирован, и пересборка повторяла бы его
|
||||||
|
вечно.
|
||||||
|
|
||||||
|
#### Scenario: В доставке нет плотных метрик
|
||||||
|
|
||||||
|
- **WHEN** ни у одной метрики доставки нет десяти точек
|
||||||
|
- **THEN** слой наследуется от последнего надёжно выведенного слоя той же
|
||||||
|
автоматизации (`automation-id`) среди доставок, **предшествующих** этой
|
||||||
|
- **AND** если наследовать нечего, слой берётся из **надёжного** заголовка
|
||||||
|
(`Minutes` → `minute`, `Hours` → `hour`)
|
||||||
|
|
||||||
|
Граница «предшествующих» обязательна: слой обязан быть функцией от префикса
|
||||||
|
журнала. Наследование от последней доставки вообще делает свёртку зависящей от
|
||||||
|
истории, и пересборка даёт не то состояние, что живой приём — измерено на
|
||||||
|
архиве, 1737 объектов против 1742.
|
||||||
|
|
||||||
|
#### Scenario: Пересборка журнала даёт то же состояние
|
||||||
|
|
||||||
|
- **WHEN** те же доставки сворачиваются повторно в том же порядке
|
||||||
|
- **THEN** число объектов и их содержимое не меняются
|
||||||
|
|
||||||
|
#### Scenario: Наследовать нечего и заголовок ненадёжен
|
||||||
|
|
||||||
|
- **WHEN** плотных метрик нет, предыдущего слоя автоматизации нет, а заголовок
|
||||||
|
равен `Default`
|
||||||
|
- **THEN** точки доставки не сохраняются, а исход учитывается счётчиком и
|
||||||
|
записью `WARN`
|
||||||
|
- **AND** тело остаётся в архиве, откуда доставку подберёт пересборка
|
||||||
|
|
||||||
|
Молчаливый выбор `raw` в этой ветке недопустим: заголовок `Default` наблюдался
|
||||||
|
одновременно у посекундного, минутного и часового режимов, поэтому он не
|
||||||
|
доказывает ничего, а призрачный `raw`-разрез попадёт в каталог и в правило
|
||||||
|
Read API «самый мелкий слой, покрывающий диапазон».
|
||||||
|
|
||||||
|
#### Scenario: Выведенный слой расходится с надёжным заголовком
|
||||||
|
|
||||||
|
- **WHEN** выведенный слой не совпадает с заголовком `Minutes` или `Hours`
|
||||||
|
- **THEN** система пишет запись уровня `WARN`
|
||||||
|
- **AND** сохраняет точки по выведенному слою, а не по заголовку
|
||||||
|
|
||||||
|
#### Scenario: Заголовок `Default` в сравнении не участвует
|
||||||
|
|
||||||
|
- **WHEN** заголовок доставки равен `Default`
|
||||||
|
- **THEN** расхождение не фиксируется и `WARN` не пишется
|
||||||
|
|
||||||
|
Иначе сигнал утонул бы в собственном шуме: `Default` не означает режима, и
|
||||||
|
сравнение с ним давало бы `WARN` на каждой доставке потока в пять минут.
|
||||||
|
|
||||||
|
### Requirement: Разбор форматов времени
|
||||||
|
|
||||||
|
Система SHALL разбирать метку точки формата `2026-07-31 21:03:51 +0300` и
|
||||||
|
приводить её к UTC, сохраняя офсет исходной зоны. В секции `data.metrics`
|
||||||
|
других форматов меток не встречается.
|
||||||
|
|
||||||
|
Unix-эпоха дробным числом (`1785446196.4132624`) встречается **внутри**
|
||||||
|
`heartbeatSeries` и меткой точки не является. Система MUST NOT преобразовывать
|
||||||
|
её: элементы серии проходят как исходные байты. Преобразование во `time.Unix`
|
||||||
|
и обратно не гарантирует дословности, а серия составляет 93% объёма метрики
|
||||||
|
`heart_rate_variability`.
|
||||||
|
|
||||||
|
RFC 3339 (`2026-07-31T18:03:51Z`) в этой дельте не нормируется: он встречается
|
||||||
|
только в `data.stateOfMind`, которая выведена из scope. Требование к нему
|
||||||
|
появится вместе с задачей про секции с собственными `id` — вместе с данными,
|
||||||
|
на которых его можно проверить.
|
||||||
|
|
||||||
|
#### Scenario: Локальное время со смещением
|
||||||
|
|
||||||
|
- **WHEN** метка имеет вид `2026-07-31 21:03:51 +0300`
|
||||||
|
- **THEN** точка получает время в UTC и офсет `+10800` секунд
|
||||||
|
|
||||||
|
#### Scenario: Время внутри серии ударов
|
||||||
|
|
||||||
|
- **WHEN** точка метрики `heart_rate_variability` содержит `heartbeatSeries`
|
||||||
|
- **THEN** элементы серии сохраняются исходными байтами вместе с их эпохой
|
||||||
|
- **AND** серия не разворачивается в отдельные точки
|
||||||
|
- **AND** эпоха внутри серии не разбирается и не преобразуется
|
||||||
|
|
||||||
|
### Requirement: Разделение схем под одним именем метрики
|
||||||
|
|
||||||
|
Система SHALL разводить на разные имена метрики те схемы, которые Health Auto
|
||||||
|
Export шлёт под одним именем, чтобы одно имя означало одну схему.
|
||||||
|
|
||||||
|
Под именем `sleep_analysis` приезжают две несовместимые схемы: поэпизодная
|
||||||
|
(`start`/`end`/`value`/`qty`) и суточная сводка
|
||||||
|
(`totalSleep`/`core`/`rem`/`deep`/`awake` с меткой на местной полуночи). Общих
|
||||||
|
полей, кроме `date` и `source`, у них нет.
|
||||||
|
|
||||||
|
Схема точки SHALL определяться по самой точке, а не по её месту в исходном
|
||||||
|
массиве. Список разобранных точек отфильтрован пропусками, и соответствие по
|
||||||
|
индексу съезжало бы от одной пропущенной точки: эпизод уезжал бы под имя
|
||||||
|
суточной сводки со слоем `day`, сводка — под имя эпизода. Метрика и слой входят
|
||||||
|
в координату, поэтому ошибка необратима — точки из объекта не удаляются, и
|
||||||
|
пересборка воспроизвела бы её.
|
||||||
|
|
||||||
|
#### Scenario: Пропущенная точка не сдвигает разметку схем
|
||||||
|
|
||||||
|
- **WHEN** в метрике `sleep_analysis` перед суточной сводкой стоит точка без
|
||||||
|
разбираемой метки
|
||||||
|
- **THEN** сводка всё равно сохраняется под именем `sleep_analysis_summary` со
|
||||||
|
слоем `day`
|
||||||
|
|
||||||
|
#### Scenario: Поэпизодная запись сна
|
||||||
|
|
||||||
|
- **WHEN** точка `sleep_analysis` содержит поле `value`
|
||||||
|
- **THEN** она сохраняется под именем `sleep_analysis`
|
||||||
|
|
||||||
|
#### Scenario: Суточная сводка сна
|
||||||
|
|
||||||
|
- **WHEN** точка `sleep_analysis` содержит поле `totalSleep`
|
||||||
|
- **THEN** она сохраняется под именем `sleep_analysis_summary`
|
||||||
|
- **AND** её слой фиксирован как `day`, а не выводится из выравнивания
|
||||||
|
|
||||||
|
### Requirement: Канонизация содержимого
|
||||||
|
|
||||||
|
Система SHALL вычислять каноническую форму содержимого точки для сравнения и
|
||||||
|
хеширования: сортировка ключей и округление чисел до двенадцати значащих цифр.
|
||||||
|
|
||||||
|
Каноническая форма существует **только в момент вычисления хеша** и хранимую
|
||||||
|
форму не заменяет никогда: хранится исходные байты (см. «Разбор секции
|
||||||
|
метрик»). Числа при канонизации читаются литералом, а не через `float64`, —
|
||||||
|
иначе округление применится к уже испорченному значению.
|
||||||
|
|
||||||
|
Сортировка ключей — свойство `encoding/json`, своей реализации не требует.
|
||||||
|
Собственным остаётся только округление.
|
||||||
|
|
||||||
|
Без округления сравнение бесполезно: 63% повторно приехавших точек различались
|
||||||
|
последним разрядом double при одинаковом измерении.
|
||||||
|
|
||||||
|
#### Scenario: Повтор с иным порядком ключей опознаётся как тот же
|
||||||
|
|
||||||
|
- **WHEN** та же точка приезжает с другим порядком ключей в JSON
|
||||||
|
- **THEN** её каноническая форма совпадает с сохранённой
|
||||||
|
|
||||||
|
#### Scenario: Дребезг последнего разряда не считается изменением
|
||||||
|
|
||||||
|
- **WHEN** значение отличается только за пределами двенадцатой значащей цифры
|
||||||
|
- **THEN** каноническая форма совпадает с сохранённой
|
||||||
|
|
||||||
|
### Requirement: Разбор не влияет на код ответа приёма
|
||||||
|
|
||||||
|
Система MUST сохранять правило «сохранили — значит приняли»: исход разбора не
|
||||||
|
меняет код ответа на доставку.
|
||||||
|
|
||||||
|
#### Scenario: Содержимое не разобралось
|
||||||
|
|
||||||
|
- **WHEN** тело сохранено в архив, но разбор его содержимого не удался
|
||||||
|
- **THEN** ответ на приём остаётся `200`
|
||||||
|
- **AND** исход виден в `delivery.parse_status` и в записи лога
|
||||||
|
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# 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** не содержит ни значений точек, ни имён устройств
|
||||||
|
|
||||||
Reference in New Issue
Block a user