заведён change на разбор метрик в часовые объекты
- proposal и две capability: parsing (вывод слоя, форматы времени, канонизация) и storage (координатный ключ, слияние по полноте, часовые объекты) - design фиксирует границы: разбор отдельным пакетом, синхронно после записи в архив, конкурентная запись в один час — риск с тестом - вне scope сознательно: тренировки, reindex, словарь кодов, род агрегации
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-01
|
||||
@@ -0,0 +1,157 @@
|
||||
## Context
|
||||
|
||||
Приём принимает пакеты Health Auto Export и складывает тела в архив
|
||||
(`internal/ingest`, `internal/archive`). Разбора нет: в SQLite только строки
|
||||
`delivery`. Накоплено 89 доставок, 16 МБ архива, поток идёт непрерывно.
|
||||
|
||||
Правила разбора выведены измерением, а не спроектированы: 46 находок в
|
||||
`docs/local-research.md`. Половина расходится с документацией HAE, поэтому
|
||||
источник истины по формату — живые пакеты, а не документация.
|
||||
|
||||
Ограничение, определяющее форму решения: **сервис нельзя останавливать**.
|
||||
Телефон шлёт непрерывно и молча; доставка, не попавшая в архив, не попадает в
|
||||
журнал вовсе — телефон её не перешлёт. Значит разбор не имеет права уронить
|
||||
приём.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Точки из секции `metrics` попадают в хранилище с правильным слоем.
|
||||
- Повторные и пересекающиеся доставки не задваивают и не затирают данные.
|
||||
- Разбор отделён от хранения: импорт родного экспорта Apple будет другим
|
||||
разбором поверх того же хранилища.
|
||||
- Ошибка разбора не влияет ни на код ответа приёма, ни на сохранность архива.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Тренировки и секции с собственными `id` — другая модель хранения.
|
||||
- `healthlog reindex` — накопленные 89 доставок доедут отдельной задачей.
|
||||
- Словарь категориальных значений: строка пока хранится дословно и без кода.
|
||||
- Род агрегации и каталог разрезов.
|
||||
- Своя агрегация при записи: слои не сводятся друг к другу никогда.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Разбор — отдельный пакет `internal/hae`, а не метод `ingest`
|
||||
|
||||
`ingest` — use-case приёма: сохранить тело, записать доставку. Разбор формата
|
||||
живёт своей жизнью: у него будет второй потребитель (`reindex`) и второй
|
||||
источник (родной экспорт Apple, свой пакет). Втянуть разбор в `ingest` значит
|
||||
получить пакет, который меняется по двум несвязанным причинам.
|
||||
|
||||
Альтернатива — разбор внутри `store`. Отвергнута: `store` не должен знать
|
||||
формат HAE, иначе импорт из Apple потребует второй реализации хранения.
|
||||
|
||||
Граница: `hae.Parse(body []byte, hdr Meta) (Parsed, error)` возвращает точки с
|
||||
уже выведенным слоем и нормализованным временем. Дальше их принимает `store`,
|
||||
который о HAE ничего не знает.
|
||||
|
||||
### Разбор — синхронно в приёме, после записи в архив
|
||||
|
||||
Тело сначала ложится на диск, потом разбирается. Отказ разбора не откатывает
|
||||
архив: журнал важнее витрины, и восстановить точки из тела можно всегда, а
|
||||
тело из точек — нет.
|
||||
|
||||
Асинхронный разбор (очередь, воркер) отвергнут: он даёт окно, в котором
|
||||
доставка принята, но не разобрана, а сервис перезапущен — и мы теряем понимание,
|
||||
что доразобрать. Синхронный разбор при 42 МБ теле стоит секунд, а `read_timeout`
|
||||
уже пять минут.
|
||||
|
||||
### Ключ объекта — `(metric, layer, hour_utc)`, содержимое — gzip-BLOB
|
||||
|
||||
Единица хранения — час, а не точка: 30 метрик × 24 часа × 365 ≈ 260 тыс. строк
|
||||
на слой в год независимо от плотности точек внутри. Строка на точку дала бы
|
||||
десятки миллионов.
|
||||
|
||||
Плата: внутрь объекта не заглянуть средствами SQL. Для хранилища, отдающего
|
||||
диапазоны точек, это не потеря; каталог и свёртка получат свои производные
|
||||
структуры отдельной задачей.
|
||||
|
||||
Сжатие наблюдалось около 25 раз — ~2 МБ в сутки вместо ~50 МБ.
|
||||
|
||||
### Слияние — по полноте, при равенстве — по `received_at`
|
||||
|
||||
Координатный ключ означает перезапись значения. Кто побеждает — решает
|
||||
полнота: 0.66% координат несут разные содержимые, и разбор выборки показал,
|
||||
что почти всё это разный **набор полей** при одинаковом `qty`. Правило «последний
|
||||
победил» стирало бы `start`/`end` у уже сохранённой точки.
|
||||
|
||||
Полнота считается по числу значащих полей точки, `source` в счёт не идёт.
|
||||
При равной полноте побеждает точка из доставки с большим `received_at` — это
|
||||
делает свёртку по журналу детерминированной: проигрывание архива обязано дать
|
||||
то же состояние, что приём в реальном времени.
|
||||
|
||||
### Слой выводится по метрике внутри доставки, а не по доставке целиком
|
||||
|
||||
Правило проверено на всей истории (находка 33) и уже дважды ломалось на живых
|
||||
данных при более простых формулировках. Классификация доставки целиком
|
||||
сложила минутные точки с посекундными и удвоила сумму за час; классификация
|
||||
каждой метрики по отдельности растащила редкие метрики по трём слоям.
|
||||
|
||||
Работающая формулировка: плотная метрика (≥10 точек) — сама по себе, редкая
|
||||
наследует самый мелкий слой среди плотных.
|
||||
|
||||
### Сводка сна — отдельное имя метрики и фиксированный слой `day`
|
||||
|
||||
Разводить схемы на имена приходится потому, что правило вывода слоя на суточной
|
||||
сводке даёт `hour` (полночь выровнена по часу), хотя это суточный итог. Имя
|
||||
`sleep_analysis_summary` — наше, не Apple; инвариант «форма Apple не
|
||||
транслируется» это не нарушает: переименования полей внутри точки нет,
|
||||
разделяются только имена метрик, под которыми HAE смешал две схемы.
|
||||
|
||||
### `testdata` — реальные пакеты с вычищенными значениями
|
||||
|
||||
Конвенция требует тестов на реальных пакетах; инвариант запрещает данным о
|
||||
здоровье попадать под контроль версий. Обе цели совместимы: в фикстурах
|
||||
сохраняется всё, что важно разбору, — порядок ключей, три формата времени,
|
||||
неразрывные пробелы в именах устройств, точность чисел, обе схемы сна,
|
||||
смешанная доставка, — а измеренные величины заменяются.
|
||||
|
||||
Скрипт порождения фикстур из архива лежит в `tmp/research/` и позволяет собрать
|
||||
их заново, когда поток принесёт новую форму.
|
||||
|
||||
Альтернатива — писать фикстуры руками по документации. Отвергнута ровно тем,
|
||||
ради чего заводилось исследование: документация врёт.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**Разбор роняет приём** → разбор идёт после `f.Sync()` и переименования файла
|
||||
архива; паника в разборе перехватывается, доставка помечается
|
||||
`parse_status=failed`, ответ остаётся `200`.
|
||||
|
||||
**Конкурентные доставки правят один час** → read-modify-write без блокировки
|
||||
теряет точки. Три автоматизации шлют одновременно, и перекрытие часов — норма,
|
||||
а не край. Запись объекта идёт в транзакции; при `SQLITE_BUSY` — повтор.
|
||||
Проверяется тестом с параллельной записью в один `hour_utc`.
|
||||
|
||||
**Правило полноты ошибочно для метрики, где меньше полей значит новее** →
|
||||
таких в потоке не наблюдалось, но допущение не доказано. Помечается как
|
||||
предположение в спеке; расхождение всплывёт при сверке с экспортом Apple.
|
||||
|
||||
**Вычищенные фикстуры прячут свойство реальных данных** → риск реален: именно
|
||||
дребезг последнего разряда double едва не увёл модель идентичности не туда.
|
||||
Смягчение — сохранять точность чисел как в оригинале и держать отдельный тест
|
||||
на канонизацию с настоящими значениями из находки 30.
|
||||
|
||||
**Объект за час распухает** → в нижнем слое HRV несёт `heartbeatSeries`, 93%
|
||||
объёма метрики. Порог не выбран, поведение при большом объекте не определено;
|
||||
наблюдаемость размера объекта уходит в задачу про `/stats`.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Миграция `00003_bucket.sql` — только добавление таблицы, существующие данные не
|
||||
трогает. Откат: сервис прежней версии игнорирует новую таблицу, доставки
|
||||
продолжают приниматься и складываться в архив, разбор просто не происходит.
|
||||
|
||||
Накопленные 89 доставок этой миграцией не разбираются: их подхватит `reindex`
|
||||
отдельной задачей. До тех пор в хранилище только точки из доставок, пришедших
|
||||
после выката.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **Порог `sealed`.** С какого возраста час считается запечатанным — ставим по
|
||||
факту: сначала `WARN` на изменение старых объектов, потом смотрим, какая
|
||||
глубина досчёта встречается в жизни (наблюдалось до 22 минут).
|
||||
- **Поведение при объекте необычного размера.** Отдельного решения пока нет;
|
||||
ждём наблюдаемости.
|
||||
@@ -0,0 +1,75 @@
|
||||
## Why
|
||||
|
||||
Приём работает третьи сутки, но дальше архива данные не идут: 89 доставок лежат
|
||||
телами `.json.gz`, а в SQLite только строки `delivery`. Ни одна из трёх целей
|
||||
проекта — агент-медик, трекер тренировок, фитнес-игра — не может прочитать
|
||||
ничего, потому что читать нечего. Каталог, Read API, MCP и OpenAPI упираются в
|
||||
эту задачу.
|
||||
|
||||
Правила разбора не надо изобретать: они выведены измерением на живом потоке и
|
||||
записаны в `docs/local-research.md` (находки 30, 33, 35, 36, 38, 39, 41).
|
||||
Задача — перенести их в код, а не спроектировать заново.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Секция `metrics` тела доставки разбирается в **точки**: метрика, слой, метка
|
||||
времени, содержимое как пришло.
|
||||
- **Слой выводится из выравнивания меток**, а не из заголовка HAE: плотная
|
||||
метрика (≥10 точек) классифицируется сама, редкая наследует преобладающий
|
||||
слой доставки. Заголовок `automation-aggregation` непригоден — значение
|
||||
`Default` соответствует трём разным режимам.
|
||||
- Точки складываются в **часовые объекты** (`bucket`, ключ `метрика + слой +
|
||||
час`), содержимое — gzip-BLOB. Запись — read-modify-write со слиянием.
|
||||
- **Идентичность точки — координаты** (`метрика + слой + метка`). `source` в
|
||||
ключ не входит: он нестабилен и меняется задним числом. При столкновении
|
||||
выигрывает **более полная** точка, а не последняя пришедшая.
|
||||
- **Канонизация с округлением** чисел до ~12 значащих цифр; хеш канонической
|
||||
формы — детектор изменений, а не ключ.
|
||||
- `sleep_analysis` разводится на два имени: поэпизодное и суточную сводку —
|
||||
под одним именем HAE шлёт две несовместимые схемы.
|
||||
- Три формата времени на входе: локальное со смещением, RFC 3339 Z,
|
||||
Unix-эпоха внутри `heartbeatSeries`. В хранилище — UTC RFC 3339 плюс офсет
|
||||
исходной зоны.
|
||||
- Разбор не влияет на код ответа приёма: непонятое содержимое по-прежнему
|
||||
даёт 200, исход разбора виден в `delivery.parse_status` и в логе.
|
||||
|
||||
Не входит в изменение (сознательно, чтобы задача мерджилась целиком):
|
||||
|
||||
- **тренировки и секции с собственными `id`** (`workouts`, `stateOfMind`,
|
||||
прочие `record`) — отдельная задача, у них другая модель хранения;
|
||||
- **`healthlog reindex`** — отдельная задача. Следствие: разбираются только
|
||||
доставки, пришедшие после выката; накопленные 89 доедут пересборкой.
|
||||
Сходимость на них проверяется скриптом поверх архива, а не командой сервиса;
|
||||
- **словарь категориальных значений** (переведённые строки → коды HealthKit) —
|
||||
отдельная задача; пока строка хранится дословно и без кода рядом;
|
||||
- **род агрегации и каталог разрезов** — отдельная задача, она следующая.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `parsing`: превращение тела доставки Health Auto Export в точки — вывод
|
||||
слоя, разбор трёх форматов времени, канонизация содержимого, разделение
|
||||
схем под одним именем метрики. Отдельно от хранения потому, что импорт
|
||||
родного экспорта Apple будет другим разбором поверх того же хранилища.
|
||||
- `storage`: идентичность точки, слияние и хранение часовыми объектами —
|
||||
координатный ключ, правило разрешения столкновений, хеш как детектор
|
||||
изменений, признак запечатанного часа.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
Нет: `openspec/specs/` пуст, приём кодом существует, но спекой не описан и в
|
||||
этом изменении не трогается.
|
||||
|
||||
## Impact
|
||||
|
||||
- Новые пакеты `internal/hae` (разбор) и расширение `internal/store` (объекты).
|
||||
- Миграция `internal/store/migrations/00003_bucket.sql` — первая миграция после
|
||||
приёма; вместе с ней заводится `docs/database.md` (ER-схема), которую требует
|
||||
шаг гейта `er-schema`.
|
||||
- `internal/ingest` получает шаг разбора после записи в архив; контракт приёма
|
||||
не меняется.
|
||||
- Появляется `testdata` с реальными пакетами HAE. Значения в них **вычищаются**:
|
||||
структура, порядок ключей, форматы времени, неразрывные пробелы в именах
|
||||
устройств и точность чисел сохраняются, измеренные величины заменяются —
|
||||
данные о здоровье не попадают под контроль версий.
|
||||
@@ -0,0 +1,137 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Разбор секции метрик
|
||||
|
||||
Система SHALL разбирать секцию `data.metrics` тела доставки Health Auto Export
|
||||
в точки. Точка несёт имя метрики, единицы, слой, метку времени и содержимое в
|
||||
том виде, в каком его прислал HAE.
|
||||
|
||||
Незнакомое поле внутри точки MUST сохраняться, а не отбрасываться: сырой архив
|
||||
недолговечен, и отброшенное поле теряется безвозвратно.
|
||||
|
||||
#### Scenario: Метрика с точками разбирается в точки
|
||||
|
||||
- **WHEN** тело содержит `data.metrics[]` с непустым `data[]`
|
||||
- **THEN** каждая точка с непустым `date` становится точкой хранилища
|
||||
- **AND** все поля точки, кроме служебных, сохраняются дословно
|
||||
|
||||
#### Scenario: Незнакомая метрика не ломает разбор
|
||||
|
||||
- **WHEN** приходит метрика с именем, которого разбор не знает
|
||||
- **THEN** её точки разбираются наравне с остальными
|
||||
- **AND** разбор не завершается ошибкой
|
||||
|
||||
#### Scenario: Точка без метки времени пропускается
|
||||
|
||||
- **WHEN** точка не содержит `date` либо `date` не разбирается ни одним из
|
||||
поддерживаемых форматов
|
||||
- **THEN** точка не попадает в хранилище
|
||||
- **AND** факт учитывается в итоге разбора доставки
|
||||
|
||||
### Requirement: Вывод слоя гранулярности
|
||||
|
||||
Система SHALL выводить слой точки из **выравнивания меток времени**, а не из
|
||||
заголовка доставки. Заголовок `automation-aggregation` непригоден: значение
|
||||
`Default` соответствует трём разным режимам выгрузки.
|
||||
|
||||
Слои: `raw` (метка на произвольной секунде), `minute` (секунды нулевые),
|
||||
`hour` (секунды и минуты нулевые).
|
||||
|
||||
Классификация MUST быть **по метрике внутри доставки**, а не по доставке
|
||||
целиком: при перенастройке автоматизации приезжают смешанные доставки, и
|
||||
отнесение такой доставки к одному слою складывает минутные точки с
|
||||
посекундными.
|
||||
|
||||
#### Scenario: Плотная метрика классифицируется сама
|
||||
|
||||
- **WHEN** в доставке у метрики не меньше десяти точек с метками
|
||||
- **THEN** слой определяется выравниванием её собственных меток
|
||||
|
||||
#### Scenario: Редкая метрика наследует преобладающий слой
|
||||
|
||||
- **WHEN** в доставке у метрики меньше десяти точек
|
||||
- **THEN** она получает самый мелкий слой среди плотных метрик этой доставки
|
||||
- **AND** её собственное выравнивание во внимание не принимается
|
||||
|
||||
#### Scenario: В доставке нет плотных метрик
|
||||
|
||||
- **WHEN** ни у одной метрики доставки нет десяти точек
|
||||
- **THEN** слой берётся из заголовка `automation-aggregation`
|
||||
(`Minutes` → `minute`, `Hours` → `hour`, иначе `raw`)
|
||||
|
||||
#### Scenario: Выведенный слой расходится с заголовком
|
||||
|
||||
- **WHEN** выведенный слой не совпадает с тем, что объявляет заголовок доставки
|
||||
- **THEN** система пишет запись уровня `WARN`
|
||||
- **AND** сохраняет точки по выведенному слою, а не по заголовку
|
||||
|
||||
### Requirement: Разбор форматов времени
|
||||
|
||||
Система SHALL понимать три формата времени, встречающиеся в теле доставки, и
|
||||
приводить их к UTC, сохраняя офсет исходной зоны.
|
||||
|
||||
- `2026-07-31 21:03:51 +0300` — метрики и тренировки;
|
||||
- `2026-07-31T18:03:51Z` — RFC 3339 в UTC;
|
||||
- `1785446196.4132624` — Unix-эпоха дробным числом внутри `heartbeatSeries`.
|
||||
|
||||
#### Scenario: Локальное время со смещением
|
||||
|
||||
- **WHEN** метка имеет вид `2026-07-31 21:03:51 +0300`
|
||||
- **THEN** точка получает время в UTC и офсет `+10800` секунд
|
||||
|
||||
#### Scenario: Время внутри серии ударов
|
||||
|
||||
- **WHEN** точка метрики `heart_rate_variability` содержит `heartbeatSeries`
|
||||
- **THEN** элементы серии сохраняются дословно вместе с их эпохой
|
||||
- **AND** серия не разворачивается в отдельные точки
|
||||
|
||||
### Requirement: Разделение схем под одним именем метрики
|
||||
|
||||
Система SHALL разводить на разные имена метрики те схемы, которые Health Auto
|
||||
Export шлёт под одним именем, чтобы одно имя означало одну схему.
|
||||
|
||||
Под именем `sleep_analysis` приезжают две несовместимые схемы: поэпизодная
|
||||
(`start`/`end`/`value`/`qty`) и суточная сводка
|
||||
(`totalSleep`/`core`/`rem`/`deep`/`awake` с меткой на местной полуночи). Общих
|
||||
полей, кроме `date` и `source`, у них нет.
|
||||
|
||||
#### Scenario: Поэпизодная запись сна
|
||||
|
||||
- **WHEN** точка `sleep_analysis` содержит поле `value`
|
||||
- **THEN** она сохраняется под именем `sleep_analysis`
|
||||
|
||||
#### Scenario: Суточная сводка сна
|
||||
|
||||
- **WHEN** точка `sleep_analysis` содержит поле `totalSleep`
|
||||
- **THEN** она сохраняется под именем `sleep_analysis_summary`
|
||||
- **AND** её слой фиксирован как `day`, а не выводится из выравнивания
|
||||
|
||||
### Requirement: Канонизация содержимого
|
||||
|
||||
Система SHALL приводить содержимое точки к канонической форме перед сравнением
|
||||
и хешированием: рекурсивная сортировка ключей и округление чисел до двенадцати
|
||||
значащих цифр.
|
||||
|
||||
Без округления сравнение бесполезно: 63% повторно приехавших точек различались
|
||||
последним разрядом double при одинаковом измерении.
|
||||
|
||||
#### Scenario: Повтор с иным порядком ключей опознаётся как тот же
|
||||
|
||||
- **WHEN** та же точка приезжает с другим порядком ключей в JSON
|
||||
- **THEN** её каноническая форма совпадает с сохранённой
|
||||
|
||||
#### Scenario: Дребезг последнего разряда не считается изменением
|
||||
|
||||
- **WHEN** значение отличается только за пределами двенадцатой значащей цифры
|
||||
- **THEN** каноническая форма совпадает с сохранённой
|
||||
|
||||
### Requirement: Разбор не влияет на код ответа приёма
|
||||
|
||||
Система MUST сохранять правило «сохранили — значит приняли»: исход разбора не
|
||||
меняет код ответа на доставку.
|
||||
|
||||
#### Scenario: Содержимое не разобралось
|
||||
|
||||
- **WHEN** тело сохранено в архив, но разбор его содержимого не удался
|
||||
- **THEN** ответ на приём остаётся `200`
|
||||
- **AND** исход виден в `delivery.parse_status` и в записи лога
|
||||
@@ -0,0 +1,100 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Идентичность точки по координатам
|
||||
|
||||
Система SHALL адресовать точку координатами `метрика + слой + метка времени`.
|
||||
Поле `source` в ключ входить MUST NOT: оно нестабильно — то же измерение с тем
|
||||
же значением приезжает то как `Apple Watch Ultra 3|iPad (Anton)`, то как
|
||||
`Apple Watch Ultra 3`, потому что Health переосмысливает атрибуцию задним
|
||||
числом.
|
||||
|
||||
Идентичность по хешу содержимого проверялась и отвергнута: она задваивала
|
||||
минутный слой целиком — 120 точек в часе вместо 60.
|
||||
|
||||
#### Scenario: Повторная доставка той же точки ничего не меняет
|
||||
|
||||
- **WHEN** точка с теми же координатами и тем же содержимым приезжает снова
|
||||
- **THEN** хранилище не изменяется
|
||||
|
||||
#### Scenario: Смена источника не создаёт вторую точку
|
||||
|
||||
- **WHEN** точка с теми же координатами приезжает с другой строкой `source`
|
||||
- **THEN** она остаётся одной точкой, а не превращается в две
|
||||
|
||||
### Requirement: Разрешение столкновений по полноте
|
||||
|
||||
Когда по одним координатам приходят разные содержимые, система SHALL оставлять
|
||||
**более полную** точку — ту, у которой больше значащих полей, — а не последнюю
|
||||
пришедшую. Иначе бедная доставка стирает `start`/`end` у богатой.
|
||||
|
||||
Если полнота равна, а значения различаются, исход определяет порядок
|
||||
воспроизведения, и он MUST быть по `received_at` доставки: свёртка по журналу
|
||||
обязана давать то же состояние, что приём в реальном времени.
|
||||
|
||||
#### Scenario: Бедная точка не стирает поля богатой
|
||||
|
||||
- **WHEN** сохранена точка с `qty`, `start` и `end`
|
||||
- **AND** по тем же координатам приезжает точка только с `qty`
|
||||
- **THEN** сохранённая точка остаётся с `start` и `end`
|
||||
|
||||
#### Scenario: Одинаково полные точки с разными значениями
|
||||
|
||||
- **WHEN** по одним координатам приходят две одинаково полные точки с разными
|
||||
значениями
|
||||
- **THEN** побеждает точка из доставки с большим `received_at`
|
||||
|
||||
### Requirement: Хранение часовыми объектами
|
||||
|
||||
Система SHALL хранить точки часовыми объектами с ключом
|
||||
`метрика + слой + час (UTC)`. Содержимое объекта — сжатый gzip блоб; точки
|
||||
внутри упорядочены по времени.
|
||||
|
||||
Запись — чтение объекта, слияние точек, запись обратно. Точки из объекта
|
||||
MUST NOT удаляться.
|
||||
|
||||
#### Scenario: Точки за один час ложатся в один объект
|
||||
|
||||
- **WHEN** приходят точки одной метрики и слоя за один час UTC
|
||||
- **THEN** они хранятся одним объектом
|
||||
|
||||
#### Scenario: Дозапись в существующий час
|
||||
|
||||
- **WHEN** приходят новые точки за уже существующий час
|
||||
- **THEN** объект перечитывается, точки сливаются, объект записывается обратно
|
||||
- **AND** ранее сохранённые точки остаются в объекте
|
||||
|
||||
### Requirement: Хеш как детектор изменений
|
||||
|
||||
Система SHALL хранить хеш канонической формы объекта и пропускать запись, если
|
||||
хеш не изменился. Хеш — детектор, а не ключ.
|
||||
|
||||
Это то, что делает широкие проходы синхронизации дешёвыми: глубокий проход
|
||||
переприсылает неделю, но почти все сравнения сходятся и записи не происходит.
|
||||
|
||||
#### Scenario: Повторная присылка того же часа не пишет в базу
|
||||
|
||||
- **WHEN** приезжает доставка, целиком повторяющая уже сохранённый час
|
||||
- **THEN** хеш совпадает и запись не выполняется
|
||||
|
||||
### Requirement: Признак запечатанного часа
|
||||
|
||||
Система SHALL отмечать признаком `sealed` часы, которые уже не должны
|
||||
меняться. Изменение запечатанного объекта — не отказ, а сигнал.
|
||||
|
||||
#### Scenario: Изменение запечатанного часа
|
||||
|
||||
- **WHEN** приходят точки за час, помеченный `sealed`
|
||||
- **THEN** система пишет запись уровня `WARN`
|
||||
- **AND** данные всё равно сохраняются
|
||||
|
||||
### Requirement: Значения точек не попадают в логи
|
||||
|
||||
Данные о здоровье чувствительнее токенов. Система MUST NOT писать значения
|
||||
точек и тела доставок в записи лога уровня выше `DEBUG`.
|
||||
|
||||
#### Scenario: Разбор доставки логируется без значений
|
||||
|
||||
- **WHEN** доставка разобрана
|
||||
- **THEN** запись лога содержит счётчики (метрик, точек, объектов) и
|
||||
идентификатор доставки
|
||||
- **AND** не содержит ни значений точек, ни имён устройств
|
||||
@@ -0,0 +1,43 @@
|
||||
## 1. Фикстуры и схема
|
||||
|
||||
- [ ] 1.1 Скрипт `tmp/research/fixtures.py`: собирает фикстуры из `data/raw`, вычищая измеренные значения и сохраняя порядок ключей, точность чисел, неразрывные пробелы и форматы времени
|
||||
- [ ] 1.2 Набор `internal/hae/testdata`: минутная доставка, посекундная, часовая, смешанная (перенастройка автоматизации), обе схемы `sleep_analysis`, точка с `heartbeatSeries`
|
||||
- [ ] 1.3 Миграция `internal/store/migrations/00003_bucket.sql`: таблица `bucket` (`metric`, `layer`, `hour_utc`, `payload` BLOB, `content_hash`, `points`, `sealed`, `created_at`, `updated_at`), уникальность по `(metric, layer, hour_utc)`
|
||||
- [ ] 1.4 `docs/database.md` — ER-схема с `delivery` и `bucket` (шаг гейта `er-schema` требует её при изменении миграций)
|
||||
|
||||
## 2. Разбор — пакет `internal/hae`
|
||||
|
||||
- [ ] 2.1 Разбор трёх форматов времени в UTC с сохранением офсета исходной зоны; неразобранная метка — не паника, а пропуск точки со счётчиком
|
||||
- [ ] 2.2 Вывод слоя: выравнивание меток, плотная метрика (≥10 точек) сама, редкая наследует преобладающий слой, при отсутствии плотных — заголовок доставки
|
||||
- [ ] 2.3 Расхождение выведенного слоя с заголовком доставки — запись `WARN` без значений точек
|
||||
- [ ] 2.4 Разделение `sleep_analysis` на поэпизодную и `sleep_analysis_summary` со слоем `day`
|
||||
- [ ] 2.5 Канонизация: рекурсивная сортировка ключей, округление чисел до 12 значащих цифр
|
||||
- [ ] 2.6 `hae.Parse` возвращает точки со слоем, метрикой, меткой и содержимым как пришло; незнакомые поля и метрики сохраняются
|
||||
- [ ] 2.7 Тесты разбора на фикстурах из 1.2, включая смешанную доставку и обе схемы сна
|
||||
|
||||
## 3. Хранение — часовые объекты
|
||||
|
||||
- [ ] 3.1 Модель точки и объекта в `internal/store`; сериализация содержимого в gzip-BLOB, точки внутри упорядочены по времени
|
||||
- [ ] 3.2 Слияние: координатный ключ `метрика + слой + метка`, победа более полной точки, при равной полноте — по `received_at`
|
||||
- [ ] 3.3 Хеш канонической формы объекта как детектор изменений: совпал — записи нет
|
||||
- [ ] 3.4 Запись объекта в транзакции; повтор при `SQLITE_BUSY`
|
||||
- [ ] 3.5 Признак `sealed`: изменение запечатанного часа пишет `WARN` и всё равно сохраняет
|
||||
- [ ] 3.6 Тест конкурентной записи в один `hour_utc`: точки обеих сторон на месте
|
||||
- [ ] 3.7 Тест идемпотентности: повторное слияние того же набора не меняет ни содержимое, ни хеш
|
||||
|
||||
## 4. Сшивка с приёмом
|
||||
|
||||
- [ ] 4.1 `internal/ingest` вызывает разбор после записи тела в архив и строки `delivery`
|
||||
- [ ] 4.2 Отказ и паника разбора не меняют код ответа: `200`, `parse_status=failed`, запись лога уровня `ERROR` без значений
|
||||
- [ ] 4.3 Единственный логирующий чекпоинт на границе: счётчики метрик, точек и объектов, идентификатор доставки; ни значений, ни имён устройств
|
||||
- [ ] 4.4 Тест приёма: битый JSON — 400, непонятое содержимое — 200 с `parse_status=failed`
|
||||
|
||||
## 5. Сходимость на реальных данных
|
||||
|
||||
- [ ] 5.1 Скрипт `tmp/research/verify_buckets.py`: прогоняет архив через разбор и сверяет суммы по часовому слою с прежней проверкой из разведки
|
||||
- [ ] 5.2 Прогон на всех накопленных доставках: расхождений по накопительным метрикам нет
|
||||
- [ ] 5.3 `task gate` зелёный; `task restart` поднимает сервис, новая доставка с телефона разбирается
|
||||
|
||||
## 6. Приёмочные критерии ревью дизайна
|
||||
|
||||
<!-- Заполняется рубрикой из healthlog-review-rubric на шаге ревью дизайна -->
|
||||
@@ -67,4 +67,5 @@ rules:
|
||||
# Кавычки обязательны: без них YAML обрежет строку на первом '#'.
|
||||
- "Каждое ### Requirement обязано содержать SHALL или MUST (иначе валидация падает)"
|
||||
- "Сценарий — ровно #### (четыре решётки); три или список молча теряются"
|
||||
- "SHALL/MUST должно стоять в ПЕРВОМ абзаце требования: валидатор смотрит только его, обоснование ниже он не видит (проверено)"
|
||||
- "Заголовки и WHEN/THEN/GIVEN — на английском, остальной текст на русском"
|
||||
|
||||
Reference in New Issue
Block a user