change razbor-metrik-v-obekty заархивирован
Дельты влиты в openspec/specs (parsing, storage), задача убрана из беклога, план отражает сделанную часть шага 3. Не закрыт один пункт: живая доставка с телефона не разобрана — поток молчит с 17:13, пауза началась до перезапуска сервиса.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-01
|
||||
@@ -0,0 +1,286 @@
|
||||
## 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 ничего не знает.
|
||||
|
||||
**Стратегия декодирования — часть контракта, а не деталь.** Конверт
|
||||
разбирается в структуру с `Data []json.RawMessage` на метрику; точка
|
||||
декодируется по одной и сразу отбрасывается. Измерено: разбор тела 42 МиБ в
|
||||
`map[string]any` удерживает 197 МиБ кучи против 54 МиБ у этой формы. Вместе с
|
||||
самим телом и удвоением в чтении пик доходит до ~300 МиБ на доставку — при
|
||||
трёх автоматизациях и неизвестном размере VPS это OOM ровно на пике потока,
|
||||
когда терять доставки дороже всего.
|
||||
|
||||
Сигнатура при этом остаётся `[]byte`: тело уже целиком в памяти после чтения
|
||||
запроса, `io.Reader` добавил бы второй буфер и ничего не сэкономил, а правило
|
||||
вывода слоя (≥10 точек по всей доставке) всё равно требует двух проходов.
|
||||
|
||||
### Дом канонизации — общий, а не внутри разбора
|
||||
|
||||
Канонизация, полнота точки и хеш живут в **нейтральном** пакете, который
|
||||
импортируют и `hae`, и `store`. Иначе граница «`store` о HAE не знает»
|
||||
оставляет их без дома: слияние происходит в `store`, после него хеш надо
|
||||
пересчитать, а канонизация лежала бы в `hae`.
|
||||
|
||||
Альтернатива — вторая реализация канонизации для импорта родного экспорта
|
||||
Apple — отвергнута: две реализации разойдутся на дребезге последнего разряда
|
||||
double, и хеш-детектор начнёт видеть изменения там, где их нет. Глубокий
|
||||
проход из почти бесплатного превратится в перезапись недели на каждом прогоне.
|
||||
|
||||
Каноническая форма существует только в момент вычисления хеша. Хранимая форма
|
||||
— исходные байты точки: числа читаются литералом (`json.Number`), потому что
|
||||
обход через `float64` теряет `1.0` → `1` и сдвигает целые больше 2^53, а
|
||||
невалидный UTF-8 в именах устройств заменяется на U+FFFD. Сортировку ключей
|
||||
делает `encoding/json`, своей писать не надо; собственным остаётся округление
|
||||
до двенадцати значащих цифр.
|
||||
|
||||
### Разбор — функция от доставки в архиве, а не от тела в памяти
|
||||
|
||||
Разбор адресуется **идентификатором доставки**, тело читается из архива. Приём
|
||||
сворачивает одну доставку, будущий `reindex` — все; код один.
|
||||
|
||||
Первая редакция дизайна отвергала это как «асинхронный разбор с очередью»,
|
||||
которым оно не является: проход по архиву — детерминированная свёртка, ровно
|
||||
то, чем система объявлена в `docs/architecture.md`
|
||||
(`состояние = import(снапшот) + replay(доставки)`). Прежняя форма давала два
|
||||
кода для одной операции — разбор при приёме и будущую пересборку, — и они
|
||||
разошлись бы на первом же расхождении.
|
||||
|
||||
Плата: тело перечитывается с диска сразу после записи. Для 42 МБ это
|
||||
страничный кэш, то есть несущественно.
|
||||
|
||||
### Свёртка вызывается синхронно, сразу после записи в архив
|
||||
|
||||
Тело ложится на диск, потом сворачивается — но уже как доставка из архива, а
|
||||
не как буфер в памяти (см. выше). Отказ свёртки не откатывает архив: журнал
|
||||
важнее витрины, восстановить точки из тела можно всегда, тело из точек — нет.
|
||||
|
||||
Синхронно, а не фоновым воркером: очередь дала бы окно «принято, но не
|
||||
свёрнуто» при перезапуске, и понадобилось бы отдельное состояние «что
|
||||
досворачивать». Свёртка одной доставки стоит секунд, а `read_timeout` уже пять
|
||||
минут.
|
||||
|
||||
Работа после записи в архив идёт на контексте, **отвязанном от запроса**
|
||||
(`context.WithoutCancel` с собственным дедлайном): иначе обрыв соединения
|
||||
клиентом или Caddy на середине свёртки оставит часть объектов записанной, а
|
||||
доставку — со статусом, по которому её никто не подберёт.
|
||||
|
||||
### Ключ объекта — `(metric, layer, hour_utc)`, содержимое — gzip-BLOB
|
||||
|
||||
Единица хранения — час, а не точка: 30 метрик × 24 часа × 365 ≈ 260 тыс. строк
|
||||
на слой в год независимо от плотности точек внутри. Строка на точку дала бы
|
||||
десятки миллионов.
|
||||
|
||||
Плата: внутрь объекта не заглянуть средствами SQL. Для хранилища, отдающего
|
||||
диапазоны точек, это не потеря; каталог и свёртка получат свои производные
|
||||
структуры отдельной задачей.
|
||||
|
||||
Сжатие наблюдалось около 25 раз — ~2 МБ в сутки вместо ~50 МБ.
|
||||
|
||||
### Координата — интервал, у измерения вырожденный
|
||||
|
||||
Ключ `метрика + слой + метка` верен для точки-измерения и неверен для
|
||||
точки-интервала: под одной меткой лежит до трёх записей сна. Ключ единый:
|
||||
|
||||
```
|
||||
координата = метрика + слой + начало + конец конец = начало, если end нет
|
||||
```
|
||||
|
||||
Замер по 94 доставкам (находка 47): метка одна даёт 170 координат сна и 33
|
||||
столкновения **внутри одной доставки**, интервал — 174 и ноль; `value` в ключе
|
||||
не добавляет ни одной координаты.
|
||||
|
||||
Первая редакция вводила отдельный класс «эпизодных схем» с признаком «`start` и
|
||||
`end`, отличные от `date`». Перепись по всем 22 метрикам с интервалами его
|
||||
опровергла:
|
||||
|
||||
- **`start` всегда равен `date`** — признак не сработал бы ни разу;
|
||||
- **интервалы несёт не только сон**, а 22 метрики, так что «эпизодная схема» —
|
||||
не класс, а норма;
|
||||
- **обе формы точки не смешиваются** внутри метрики одной доставки, поэтому
|
||||
единый ключ не разорвёт надвое точку, приехавшую то с `end`, то без;
|
||||
- **разные интервалы под одной меткой всегда несут разное содержимое**
|
||||
(проверено по всем метрикам), так что ключ с интервалом не задваивает
|
||||
поправленное задним числом.
|
||||
|
||||
Одна форма ключа вместо двух убирает из кода ветвление и понятие, которое
|
||||
пришлось бы поддерживать в каталоге, Read API и импорте экспорта Apple.
|
||||
|
||||
Почему не «принять потерю и писать `WARN`»: в дублях внутри одной доставки
|
||||
`received_at` общий, и тай-брейк по времени приёма неприменим в принципе —
|
||||
исход решал бы порядок элементов в JSON-массиве, а он нестабилен (находка 2).
|
||||
Свёртка перестала бы быть детерминированной: пересборка из архива давала бы не
|
||||
то состояние, что живой приём.
|
||||
|
||||
Почему не append-only по хешу содержимого: это вторая модель идентичности в
|
||||
`store` ради случая, которого можно избежать, и эпизоды не схлопывались бы
|
||||
никогда — даже когда повтор действительно повтор, а их здесь 1706 из 1880.
|
||||
|
||||
Проверено против чужих решений (находка 47): единственная принятая схема
|
||||
дедупликации Apple Health — `Start + End + тип`, без источника в ключе;
|
||||
популярные ингесторы поверх InfluxDB ключуют по метке и теряют эпизоды
|
||||
молчаливым last-write-wins движка. `HKObject.uuid` дал бы идентичность даром,
|
||||
но **в выгрузку Apple он не попадает** — значит модель обязана выражаться через
|
||||
`start`/`end`, иначе `import(экспорт)` не сойдётся с `replay(HAE)`.
|
||||
|
||||
### Слияние — по полноте, при равенстве — по `received_at`
|
||||
|
||||
Координатный ключ означает перезапись значения. Кто побеждает — решает
|
||||
полнота: 0.66% координат несут разные содержимые, и разбор выборки показал,
|
||||
что почти всё это разный **набор полей** при одинаковом `qty`. Правило «последний
|
||||
победил» стирало бы `start`/`end` у уже сохранённой точки.
|
||||
|
||||
Полнота считается по числу значащих полей точки, `source` в счёт не идёт.
|
||||
|
||||
При **равной** полноте исход обязан быть детерминированным и не зависеть от
|
||||
порядка доставок. Первая редакция предписывала сравнение по `received_at` —
|
||||
оно неисполнимо: у сохранённой точки нет провенанса, сравнивать не с чем. Хуже,
|
||||
что четверть доставок несёт столкновения **внутри себя**, где `received_at`
|
||||
вообще один. Поэтому исход определяется свойством самих значений (порядком
|
||||
канонических форм), а не порядком событий.
|
||||
|
||||
Столкновение с различием содержимого оставляет след — `WARN` и счётчик. Иначе
|
||||
допущение «меньше полей не значит новее», объявленное риском, не получит ни
|
||||
одного наблюдения.
|
||||
|
||||
### Слой выводится по метрике внутри доставки, а не по доставке целиком
|
||||
|
||||
Правило проверено на всей истории (находка 33) и уже дважды ломалось на живых
|
||||
данных при более простых формулировках. Классификация доставки целиком
|
||||
сложила минутные точки с посекундными и удвоила сумму за час; классификация
|
||||
каждой метрики по отдельности растащила редкие метрики по трём слоям.
|
||||
|
||||
Работающая формулировка: плотная метрика (≥10 точек) — сама по себе, редкая
|
||||
наследует самый мелкий слой среди плотных.
|
||||
|
||||
Третий шаг — доставка без плотных метрик вовсе — наследует последний надёжно
|
||||
выведенный слой той же автоматизации. Первая редакция заменила его заголовком,
|
||||
и это была регрессия: измерено 2 такие доставки из 89, обе с заголовком
|
||||
`Default`, который не означает режима. Наследовать нечего и заголовок
|
||||
ненадёжен — точки не сохраняются, доставка ждёт пересборки; молчаливый `raw`
|
||||
создал бы призрачный разрез, который поедет в каталог и в выбор слоя Read API.
|
||||
|
||||
### Сводка сна — отдельное имя метрики и фиксированный слой `day`
|
||||
|
||||
Разводить схемы на имена приходится потому, что правило вывода слоя на суточной
|
||||
сводке даёт `hour` (полночь выровнена по часу), хотя это суточный итог. Имя
|
||||
`sleep_analysis_summary` — наше, не Apple; инвариант «форма Apple не
|
||||
транслируется» это не нарушает: переименования полей внутри точки нет,
|
||||
разделяются только имена метрик, под которыми HAE смешал две схемы.
|
||||
|
||||
### `testdata` — реальные пакеты с вычищенными значениями
|
||||
|
||||
Конвенция требует тестов на реальных пакетах; инвариант запрещает данным о
|
||||
здоровье попадать под контроль версий. Обе цели совместимы: в фикстурах
|
||||
сохраняется всё, что важно разбору, — порядок ключей, три формата времени,
|
||||
неразрывные пробелы в именах устройств, точность чисел, обе схемы сна,
|
||||
смешанная доставка, — а измеренные величины заменяются.
|
||||
|
||||
Скрипт порождения фикстур из архива лежит в `tmp/research/` и позволяет собрать
|
||||
их заново, когда поток принесёт новую форму.
|
||||
|
||||
Альтернатива — писать фикстуры руками по документации. Отвергнута ровно тем,
|
||||
ради чего заводилось исследование: документация врёт.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**Разбор роняет приём** → разбор идёт после `f.Sync()` и переименования файла
|
||||
архива; паника в разборе перехватывается, доставка помечается
|
||||
`parse_status=failed`, ответ остаётся `200`.
|
||||
|
||||
**Конкурентные доставки правят один час** → read-modify-write теряет точки, и
|
||||
наивное «транзакция плюс повтор» **измеримо не работает**. Замер на
|
||||
`modernc.org/sqlite` с DSN проекта, 4 горутины × 200 слияний в одну строку:
|
||||
|
||||
```
|
||||
txlock=deferred без повтора 91 из 800
|
||||
txlock=deferred с повтором 242 из 800 (31078 повторов)
|
||||
txlock=immediate без повтора 800 из 800
|
||||
```
|
||||
|
||||
Причина: код отказа — `517` (`SQLITE_BUSY_SNAPSHOT`), и `busy_timeout` его не
|
||||
покрывает, SQLite возвращает его немедленно. Поэтому: `_txlock=immediate` в
|
||||
DSN; повтор оборачивает **всю тройку** чтение-слияние-запись, а не только
|
||||
запись (иначе повтор перезапишет чужие точки уже прочитанным состоянием —
|
||||
классический lost update); путь «хеш совпал, писать нечего» идёт под
|
||||
`TxOptions{ReadOnly: true}`, чтобы не сериализоваться на write-lock;
|
||||
распознавание — `errors.As` на `*sqlite.Error` с кодами 5 и 517, обёрнутое в
|
||||
`store`, чтобы драйвер не торчал наружу.
|
||||
|
||||
Тест обязан быть с настоящей конкуренцией и проверкой суммы: две горутины в
|
||||
удачном порядке проходят и на сломанной реализации.
|
||||
|
||||
**Правило полноты ошибочно для метрики, где меньше полей значит новее** →
|
||||
таких в потоке не наблюдалось, но допущение не доказано. Помечается как
|
||||
предположение в спеке; расхождение всплывёт при сверке с экспортом Apple.
|
||||
|
||||
**Вычищенные фикстуры прячут свойство реальных данных** → риск реален: именно
|
||||
дребезг последнего разряда double едва не увёл модель идентичности не туда.
|
||||
Смягчение — сохранять точность чисел как в оригинале и держать отдельный тест
|
||||
на канонизацию с настоящими значениями из находки 30.
|
||||
|
||||
**Объект за час распухает** → в нижнем слое HRV несёт `heartbeatSeries`, 93%
|
||||
объёма метрики. Порог не выбран, поведение при большом объекте не определено;
|
||||
наблюдаемость размера объекта уходит в задачу про `/stats`.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Миграция `00003_bucket.sql` — только добавление таблицы, существующие данные не
|
||||
трогает. Откат: сервис прежней версии игнорирует новую таблицу, доставки
|
||||
продолжают приниматься и складываться в архив, разбор просто не происходит.
|
||||
|
||||
Накопленные 89 доставок этой миграцией не разбираются: их подхватит `reindex`
|
||||
отдельной задачей. До тех пор в хранилище только точки из доставок, пришедших
|
||||
после выката.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **Порог `sealed`.** С какого возраста час считается запечатанным — ставим по
|
||||
факту: сначала `WARN` на изменение старых объектов, потом смотрим, какая
|
||||
глубина досчёта встречается в жизни (наблюдалось до 22 минут).
|
||||
- **Поведение при объекте необычного размера.** Отдельного решения пока нет;
|
||||
ждём наблюдаемости.
|
||||
@@ -0,0 +1,81 @@
|
||||
## 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 со слиянием.
|
||||
- **Идентичность точки — координаты** (`метрика + слой + начало + конец`; у
|
||||
точки-измерения конец равен началу). Ключ одной формы для всех точек: под
|
||||
одной меткой лежит до трёх записей сна, и 33 столкновения происходят внутри
|
||||
одной доставки, где тай-брейк по времени приёма неприменим. `source` в ключ
|
||||
не входит: он нестабилен и меняется задним числом. При столкновении
|
||||
выигрывает **более полная** точка, а не последняя пришедшая.
|
||||
- **Канонизация с округлением** чисел до ~12 значащих цифр; хеш канонической
|
||||
формы — детектор изменений, а не ключ.
|
||||
- `sleep_analysis` разводится на два имени: поэпизодное и суточную сводку —
|
||||
под одним именем HAE шлёт две несовместимые схемы.
|
||||
- Метка точки разбирается из локального времени со смещением и хранится в UTC
|
||||
плюс офсет исходной зоны. Эпоха внутри `heartbeatSeries` **не разбирается** —
|
||||
серия проходит исходными байтами; RFC 3339 встречается только в
|
||||
`stateOfMind` и нормируется вместе с ним, отдельной задачей.
|
||||
- Разбор не влияет на код ответа приёма: непонятое содержимое по-прежнему
|
||||
даёт 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/canon` (каноническая форма,
|
||||
полнота, хеш — общий дом для разбора и хранения) и расширение
|
||||
`internal/store` (объекты).
|
||||
- Миграция `internal/store/migrations/00003_bucket.sql` — первая миграция после
|
||||
приёма; вместе с ней заводится `docs/database.md` (ER-схема), которую требует
|
||||
шаг гейта `er-schema`.
|
||||
- `internal/ingest` получает шаг разбора после записи в архив; контракт приёма
|
||||
не меняется.
|
||||
- Появляется `testdata` с реальными пакетами HAE. Значения в них **вычищаются**:
|
||||
структура, порядок ключей, форматы времени, неразрывные пробелы в именах
|
||||
устройств и точность чисел сохраняются, измеренные величины заменяются —
|
||||
данные о здоровье не попадают под контроль версий.
|
||||
@@ -0,0 +1,256 @@
|
||||
## ADDED 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,211 @@
|
||||
## ADDED 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** не содержит ни значений точек, ни имён устройств
|
||||
@@ -0,0 +1,96 @@
|
||||
## 1. Фикстуры и схема
|
||||
|
||||
- [x] 1.1 Скрипт `tmp/research/fixtures.py`: собирает фикстуры из `data/raw`, вычищая измеренные значения и сохраняя порядок ключей, точность чисел, неразрывные пробелы и форматы времени
|
||||
- [x] 1.2 Набор `internal/hae/testdata`: минутная доставка, посекундная, часовая, смешанная (перенастройка автоматизации), обе схемы `sleep_analysis`, точка с `heartbeatSeries`, доставка без плотных метрик с заголовком `Default`
|
||||
- [x] 1.3a Фикстура с эпизодами сна: три точки с одной меткой `date` и разными парами `start`/`end`, включая эпизод, пересекающий границу часа (в архиве такие есть — вычистить значения, интервалы сохранить)
|
||||
- [x] 1.3 Рукотворная фикстура с **выдуманным полем точки** — скрипт из архива её не породит, а без неё требование «незнакомое поле сохраняется» останется без теста
|
||||
- [x] 1.4 Миграция `internal/store/migrations/00003_bucket.sql`: таблица `bucket` (`metric`, `layer`, `hour_utc`, `units`, `payload` BLOB, `content_hash`, `points`, `first_ts`, `last_ts`, `first_delivery_id`, `sealed`, `created_at`, `updated_at`), уникальность по `(metric, layer, hour_utc)`
|
||||
- [x] 1.5 `docs/database.md` — ER-схема с `delivery` и `bucket` (шаг гейта `er-schema` требует её при изменении миграций)
|
||||
|
||||
## 2. Канонизация — общий дом
|
||||
|
||||
- [x] 2.1 Пакет `internal/canon`: каноническая форма (числа читаются литералом через `json.Number`, округление до 12 значащих цифр — именованная константа со ссылкой на находку 30), полнота точки, хеш
|
||||
- [x] 2.2 Сортировку ключей **не писать** — её делает `encoding/json`
|
||||
- [x] 2.3 Тест канонизации на настоящих парах чисел из находки 30: все три схлопываются
|
||||
- [x] 2.4 Тест: значение с `1.0`, целым больше 2^53 и невалидным UTF-8 переживает хранение дословно
|
||||
|
||||
## 3. Разбор — пакет `internal/hae`
|
||||
|
||||
- [x] 3.1 Декодирование конверта в структуру с `Data []json.RawMessage`; точка декодируется по одной. Тест удержания кучи на теле в десятки МиБ
|
||||
- [x] 3.2 Разбор метки `2006-01-02 15:04:05 -0700` в UTC с сохранением офсета; неразобранная метка — пропуск точки со счётчиком, не паника
|
||||
- [x] 3.3 Эпоха внутри `heartbeatSeries` **не разбирается**: элементы проходят исходными байтами
|
||||
- [x] 3.4 Вывод слоя: плотная метрика (≥10 точек) сама, редкая наследует преобладающий, доставка без плотных — наследует последний слой автоматизации по `automation-id`
|
||||
- [x] 3.5 Наследовать нечего и заголовок `Default` — точки не сохраняются, `WARN` и счётчик
|
||||
- [x] 3.6 `WARN` о расхождении слоя — только против `Minutes`/`Hours`; `Default` в сравнении не участвует
|
||||
- [x] 3.7 Разделение `sleep_analysis` на поэпизодную и `sleep_analysis_summary` со слоем `day`; выполняется **до** вывода слоя, точки с назначенным слоем в голосовании не участвуют
|
||||
- [x] 3.8 `recover` **внутри** `hae.Parse` — превращает панику разбора в ошибку пакета; паника из `store` наверх не перехватывается
|
||||
- [x] 3.9 Контракт: `error` ненулевая только когда точек нет вовсе; частичные исходы — счётчиками в результате
|
||||
- [x] 3.10 `FuzzParse` и таблица враждебных входов: усечённое тело, `null` вместо объекта, массив вместо `data`, число вместо строки даты
|
||||
- [x] 3.11 Тесты разбора на фикстурах 1.2–1.3, включая смешанную доставку и обе схемы сна
|
||||
|
||||
## 4. Хранение — часовые объекты
|
||||
|
||||
- [x] 4.1 Модель объекта в `internal/store`; содержимое — исходные байты точек, gzip-BLOB, точки упорядочены по времени
|
||||
- [x] 4.2 Слияние: координатный ключ, победа более полной точки, при равной полноте — детерминированный исход по порядку канонических форм
|
||||
- [x] 4.3 Столкновение с различием канонической формы — `WARN` без значений и счётчик перезаписей
|
||||
- [x] 4.4 Ключ точки — `метрика + слой + начало + конец` одной формы для всех точек: начало из `start`, иначе из `date`; конец из `end`, иначе равен началу. Час объекта — по началу. Ветвления по «классу метрики» быть не должно
|
||||
- [x] 4.4a Тест на фикстуре 1.3a: три записи с одной меткой и разными интервалами дают три точки, а не одну; повтор той же тройки следующей доставкой не задваивает; точка без `end` кладётся вырожденным интервалом
|
||||
- [x] 4.5 Хеш объекта как детектор изменений: совпал — записи нет
|
||||
- [x] 4.6 `_txlock=immediate` в DSN; повтор оборачивает **всю доставку** целиком. Путь «хеш совпал» под `TxOptions{ReadOnly: true}` **не сделан** — вынесен блокером `otvet-i-svyortka` вместе с остальной ценой синхронной свёртки (измерено: 52 мс на неизменившийся плотный час под write-lock)
|
||||
- [x] 4.7 Распознавание занятости — `errors.As` на `*sqlite.Error`, коды 5 и 517, обёрнуто в `store`
|
||||
- [x] 4.8 Тест конкурентной записи: N горутин × M слияний в один `hour_utc`, проверка **суммы** точек, под `-race`
|
||||
- [x] 4.9 Тест идемпотентности: повторное слияние того же набора не меняет ни содержимое, ни хеш
|
||||
- [x] 4.10 Тесты сценариев слияния: «бедная точка не стирает поля богатой», «смена `source` не создаёт вторую точку»
|
||||
|
||||
## 5. Сшивка с приёмом
|
||||
|
||||
- [x] 5.1 Свёртка вызывается по идентификатору доставки, тело читается из архива — один код с будущей пересборкой
|
||||
- [x] 5.2 Работа после записи в архив — на `context.WithoutCancel` с собственным дедлайном: обрыв соединения не рвёт запись объектов
|
||||
- [x] 5.3 Отказ разбора не меняет код ответа: `200`, `parse_status=failed`, запись `ERROR` без значений
|
||||
- [x] 5.4 Единственный логирующий чекпоинт на границе: счётчики метрик, точек, объектов, пропусков, перезаписей; идентификатор доставки; ни значений, ни имён устройств
|
||||
- [x] 5.5 Тест приёма: битый JSON — 400, непонятое содержимое — 200 с `parse_status=failed`
|
||||
|
||||
## 6. Сходимость на реальных данных
|
||||
|
||||
- [x] 6.1 Проверка сходимости — не отдельным скриптом, а `task verify:archive` поверх `internal/fold/replay_test.go`: одна реализация вместо двух, и она гоняет ровно продакшн-путь
|
||||
- [x] 6.2 Прогон на всех накопленных доставках: расхождений по накопительным метрикам нет
|
||||
- [x] 6.3 `task gate` зелёный; `task restart` поднимает сервис
|
||||
- [ ] 6.3a **Не закрыто:** живая доставка с телефона не разобрана — поток молчит с 17:13 (пауза началась ЗА ЧАС до перезапуска, то есть не из-за него). Сервис поднят и здоров, миграции на живой базе применились, схема проверена чтением. Пункт закрывается первой же пришедшей доставкой
|
||||
|
||||
## 7. Приёмочные критерии (рубрика ревью дизайна)
|
||||
|
||||
Порождена проходом `healthlog-review-rubric` **до** чтения предложения. Каждый
|
||||
пункт проверяем: понятно, каким тестом его провалить.
|
||||
|
||||
- [x] 7.1 Отсутствие паники на произвольном входе — усечённый JSON, `null` вместо объекта, массив вместо объекта, число вместо строки даты
|
||||
- [x] 7.2 Незнакомое поле точки переживает round-trip дословно
|
||||
- [x] 7.3 Идемпотентность повторной записи, включая переставленный порядок ключей и точек
|
||||
- [x] 7.4 Различие не теряется молча: перезапись и изменение запечатанного часа оставляют след
|
||||
- [x] 7.5 Конкурентное слияние того же часа не теряет точки — под `-race`, с проверкой суммы
|
||||
- [x] 7.6 Отмена посреди слияния не оставляет половинчатого состояния: объект либо прежний, либо полный
|
||||
- [x] 7.7 Граница размера входа явная; вход в сотни мегабайт не кладёт процесс по памяти
|
||||
- [x] 7.8 Ошибки различимы по типу, а не по тексту (`errors.Is`/`errors.As`)
|
||||
- [x] 7.9 Частично непонятный пакет имеет явную судьбу: что сохранено, что отброшено — видно в счётчиках
|
||||
- [x] 7.10 Время нормализовано без потери зоны
|
||||
- [x] 7.11 Слой выводится из данных, а не из заголовка; неопределимый слой имеет явную судьбу
|
||||
- [x] 7.12 Парсер детерминирован и чист: ни `time.Now`, ни генерации id; два вызова на одном входе равны
|
||||
- [x] 7.13 (добавлено после снятия блокера) Точка-интервал не схлопывается по метке: прогон архива даёт 174 координаты сна, а не 170
|
||||
|
||||
## 8. Отработка ревью кода (профиль `deep`)
|
||||
|
||||
Триаж свёл 62 сырые находки к 33 причинам: 3 блокера, 4 «сейчас», 2 развилки.
|
||||
|
||||
- [x] 8.1 Схема точки сна определяется по самой точке, а не по индексу в исходном массиве (подтверждено шестью проходами)
|
||||
- [x] 8.2 Доставка сворачивается одной транзакцией: частичное состояние было недетерминированным (8 прогонов — 7 состояний)
|
||||
- [x] 8.3 Граница размера на распакованном теле: 400 КиБ gzip разворачивались в 400 МиБ мимо лимита
|
||||
- [x] 8.4 Столкновение — расхождение канонических форм, а не байтов; `WARN` с координатами объекта
|
||||
- [x] 8.5 `encodePayload` без HTML-экранирования: `&`, `<`, `>` хранятся дословно
|
||||
- [x] 8.6 Доставка из одних суточных сводок больше не отвергается целиком
|
||||
- [x] 8.7 Выравнивание считается по местной метке: получасовые зоны уводили часовую выгрузку в `minute`
|
||||
- [x] 8.8 Нечитаемый `end` пропускает точку со счётчиком, а не вырождает интервал
|
||||
- [x] 8.9 Единицы не переписываются молча: сохранённое побеждает, расхождение — счётчик и `WARN`
|
||||
- [x] 8.10 Счётчик считает сохранённые точки, а не присланные
|
||||
- [x] 8.11 Все выходы `Fold` с ошибкой логируются; исход пишется на контексте, переживающем отмену; слой не затирается
|
||||
- [x] 8.12 Признаки — атрибутами всегда, уровень выбирается отдельно; `WARN` на отброшенных всех точках
|
||||
- [x] 8.13 Регрессионные тесты на каждое исправление; тест «значения точек не попадают в лог» с перехватывающим handler
|
||||
- [x] 8.14 Развилки вынесены блокерами: `otvet-i-svyortka`, `pravilo-sliyaniya-tochek`, `edinicy-metriki-v-razreze`, `nerazobrannye-sekcii-dostavki`
|
||||
Reference in New Issue
Block a user