предложение переработано по итогам ревью дизайна
- три решения опровергнуты экспериментами: повтор при SQLITE_BUSY не сходится без _txlock=immediate (242 из 800 против 800 из 800), разбор в map[string]any держит 197 МиБ против 54, канонизация без json.Number теряет литерал - канонизации назначен дом: общий internal/canon вместо hae, иначе импорт экспорта Apple потребует второй реализации и хеш-детектор станет бесполезен - вывод слоя вернул шаг наследования, WARN сравнивается только с надёжным заголовком; в bucket возвращены units и границы содержимого
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
одному — прерывать поток ради каждого дороже, чем накопить.
|
одному — прерывать поток ради каждого дороже, чем накопить.
|
||||||
|
|
||||||
## блокеры
|
## блокеры
|
||||||
|
- [Идентичность эпизодных метрик](identichnost-epizodnyh-metrik.md) — Координатный ключ схлопывает эпизоды сна: 31 координата в 21 доставке из 89, тай-брейк неисполним
|
||||||
|
|
||||||
## высокий
|
## высокий
|
||||||
- [Разбор метрик в часовые объекты](razbor-metrik-v-obekty.md) — Доставки копятся непрозрачными телами — точек в хранилище нет вовсе, всё остальное упирается в это
|
- [Разбор метрик в часовые объекты](razbor-metrik-v-obekty.md) — Доставки копятся непрозрачными телами — точек в хранилище нет вовсе, всё остальное упирается в это
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Идентичность эпизодных метрик
|
||||||
|
|
||||||
|
**Приоритет:** блокеры
|
||||||
|
|
||||||
|
Вынуто из задачи `razbor-metrik-v-obekty` ревью дизайна (профиль `design`,
|
||||||
|
находка №1 триажа, severity critical). Пока не решено — эпизодные схемы не
|
||||||
|
хранятся, см. «Что стоит без решения».
|
||||||
|
|
||||||
|
## Что решить
|
||||||
|
|
||||||
|
Состав координатного ключа и правило слияния для метрик, у которых **метка
|
||||||
|
времени не уникальна**. Принятая модель — `метрика + слой + метка` — для них
|
||||||
|
неверна.
|
||||||
|
|
||||||
|
## Оракул: измерено на живых данных
|
||||||
|
|
||||||
|
Из 173 координат сна три несут разное содержимое, одна — три разных эпизода:
|
||||||
|
|
||||||
|
```
|
||||||
|
sleep_analysis 2026-07-31 22:04:00
|
||||||
|
start=22:04 end=22:16 qty=0.2 value=Во сне
|
||||||
|
start=22:04 end=02:21 qty=4.28 value=В кровати
|
||||||
|
start=22:04 end=07:51 qty=9.78 value=В кровати
|
||||||
|
```
|
||||||
|
|
||||||
|
Хуже: **31 координата в 21 доставке из 89** (почти четверть) задвоена **внутри
|
||||||
|
одной доставки**. Там `received_at` у обеих точек один и тот же, поэтому
|
||||||
|
предписанный тай-брейк «побеждает больший `received_at`» неприменим в
|
||||||
|
принципе — исход решил бы порядок элементов в JSON-массиве, а он нестабилен
|
||||||
|
(находка 2). Это ломало бы и детерминированность свёртки: пересборка из архива
|
||||||
|
давала бы другое состояние, чем живой приём.
|
||||||
|
|
||||||
|
Правило полноты не спасает: точка сна несёт одновременно `start`/`end` и
|
||||||
|
`startDate`/`endDate`, так что два набора разных полей равной мощности
|
||||||
|
равнополны.
|
||||||
|
|
||||||
|
## Варианты и цена
|
||||||
|
|
||||||
|
**(А) Включить `start`/`end` в координату для эпизодных схем.**
|
||||||
|
Закрывает и междоставочные, и внутридоставочные дубли — все 31 относятся к
|
||||||
|
`sleep_analysis`. Цена: ключ разной формы для разных классов метрик, и надо
|
||||||
|
определить, что делает метрику «эпизодной» (наличие `start`/`end`? список?).
|
||||||
|
|
||||||
|
**(Б) Эпизодные метрики — множество с идентичностью по хешу канонической формы
|
||||||
|
(append-only).** Ничего не теряется по построению. Цена: две модели
|
||||||
|
идентичности в `store` и рост объекта — эпизоды не схлопываются никогда, даже
|
||||||
|
когда повтор действительно повтор.
|
||||||
|
|
||||||
|
**(В) Принять потерю: нынешнее правило плюс обязательный `WARN`.**
|
||||||
|
Дёшево. Цена: необратимая потеря эпизодов примерно в четверти доставок сна,
|
||||||
|
обнаружимая только сверкой с экспортом Apple, то есть месяцами позже. Прямо
|
||||||
|
противоречит инварианту «ничего не теряем молча».
|
||||||
|
|
||||||
|
Независимо от выбора: тай-брейк `received_at` надо заменить или дополнить
|
||||||
|
детерминированным (например, лексикографически по канонической форме) — без
|
||||||
|
провенанса в схеме нынешний неисполним даже между доставками.
|
||||||
|
|
||||||
|
## Рекомендация
|
||||||
|
|
||||||
|
**(А).** Она закрывает оба класса дублей, не плодит вторую модель хранения и не
|
||||||
|
требует принимать потерю. «Эпизодность» выводится из данных, а не курируется:
|
||||||
|
точка, несущая `start` и `end`, отличные от `date`, — эпизод. Это в духе того,
|
||||||
|
как в проекте уже выводится слой — из формы данных, а не из объявления.
|
||||||
|
|
||||||
|
Против (Б): рост объекта ради случая, которого можно избежать. Против (В): это
|
||||||
|
ровно тот класс молчаливой потери, ради защиты от которого заведён инвариант.
|
||||||
|
|
||||||
|
## Что стоит без решения
|
||||||
|
|
||||||
|
`sleep_analysis` (поэпизодная схема) в задаче `razbor-metrik-v-obekty` **не
|
||||||
|
сохраняется**: точки разбираются и считаются, но в объекты не пишутся. Сохранять
|
||||||
|
их по правилу, о котором известно, что оно теряет, — хуже, чем не сохранять:
|
||||||
|
тела лежат в архиве, и после решения блокера их подберёт `reindex`.
|
||||||
|
|
||||||
|
Суточная сводка (`sleep_analysis_summary`) блокером не затронута — у неё метка
|
||||||
|
на полуночи уникальна.
|
||||||
|
|
||||||
@@ -24,8 +24,14 @@ Read API, MCP — стоит на этой задаче.
|
|||||||
- слияние точек в объект read-modify-write, хеш объекта как детектор изменений;
|
- слияние точек в объект read-modify-write, хеш объекта как детектор изменений;
|
||||||
- `docs/database.md` — ER-схема (её требует шаг гейта `er-schema`).
|
- `docs/database.md` — ER-схема (её требует шаг гейта `er-schema`).
|
||||||
|
|
||||||
Готово, когда по существующим 89 доставкам собирается хранилище, а суммы по
|
**Границы после ревью дизайна.** Поэпизодный `sleep_analysis` в этой задаче не
|
||||||
часовому слою сходятся с проверкой из `tmp/research/`.
|
сохраняется — модель идентичности вынесена блокером
|
||||||
|
[identichnost-epizodnyh-metrik](identichnost-epizodnyh-metrik.md). Остальное
|
||||||
|
делается целиком.
|
||||||
|
|
||||||
|
Готово, когда по существующим 89 доставкам собирается хранилище (кроме
|
||||||
|
эпизодов сна), а суммы по часовому слою сходятся с проверкой из
|
||||||
|
`tmp/research/`.
|
||||||
|
|
||||||
Связано: `docs/architecture.md` → «Хранилище», план шаг 3.
|
Связано: `docs/architecture.md` → «Хранилище», план шаг 3.
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,10 @@
|
|||||||
- Словарь категориальных значений: строка пока хранится дословно и без кода.
|
- Словарь категориальных значений: строка пока хранится дословно и без кода.
|
||||||
- Род агрегации и каталог разрезов.
|
- Род агрегации и каталог разрезов.
|
||||||
- Своя агрегация при записи: слои не сводятся друг к другу никогда.
|
- Своя агрегация при записи: слои не сводятся друг к другу никогда.
|
||||||
|
- **Хранение эпизодных схем** (поэпизодный `sleep_analysis`): модель их
|
||||||
|
идентичности вынесена блокером `identichnost-epizodnyh-metrik`. Точки
|
||||||
|
разбираются и считаются, но не сохраняются; тела в архиве, подберёт
|
||||||
|
пересборка.
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
@@ -47,16 +51,67 @@
|
|||||||
уже выведенным слоем и нормализованным временем. Дальше их принимает `store`,
|
уже выведенным слоем и нормализованным временем. Дальше их принимает `store`,
|
||||||
который о HAE ничего не знает.
|
который о HAE ничего не знает.
|
||||||
|
|
||||||
### Разбор — синхронно в приёме, после записи в архив
|
**Стратегия декодирования — часть контракта, а не деталь.** Конверт
|
||||||
|
разбирается в структуру с `Data []json.RawMessage` на метрику; точка
|
||||||
|
декодируется по одной и сразу отбрасывается. Измерено: разбор тела 42 МиБ в
|
||||||
|
`map[string]any` удерживает 197 МиБ кучи против 54 МиБ у этой формы. Вместе с
|
||||||
|
самим телом и удвоением в чтении пик доходит до ~300 МиБ на доставку — при
|
||||||
|
трёх автоматизациях и неизвестном размере VPS это OOM ровно на пике потока,
|
||||||
|
когда терять доставки дороже всего.
|
||||||
|
|
||||||
Тело сначала ложится на диск, потом разбирается. Отказ разбора не откатывает
|
Сигнатура при этом остаётся `[]byte`: тело уже целиком в памяти после чтения
|
||||||
архив: журнал важнее витрины, и восстановить точки из тела можно всегда, а
|
запроса, `io.Reader` добавил бы второй буфер и ничего не сэкономил, а правило
|
||||||
тело из точек — нет.
|
вывода слоя (≥10 точек по всей доставке) всё равно требует двух проходов.
|
||||||
|
|
||||||
Асинхронный разбор (очередь, воркер) отвергнут: он даёт окно, в котором
|
### Дом канонизации — общий, а не внутри разбора
|
||||||
доставка принята, но не разобрана, а сервис перезапущен — и мы теряем понимание,
|
|
||||||
что доразобрать. Синхронный разбор при 42 МБ теле стоит секунд, а `read_timeout`
|
Канонизация, полнота точки и хеш живут в **нейтральном** пакете, который
|
||||||
уже пять минут.
|
импортируют и `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
|
### Ключ объекта — `(metric, layer, hour_utc)`, содержимое — gzip-BLOB
|
||||||
|
|
||||||
@@ -78,9 +133,17 @@
|
|||||||
победил» стирало бы `start`/`end` у уже сохранённой точки.
|
победил» стирало бы `start`/`end` у уже сохранённой точки.
|
||||||
|
|
||||||
Полнота считается по числу значащих полей точки, `source` в счёт не идёт.
|
Полнота считается по числу значащих полей точки, `source` в счёт не идёт.
|
||||||
При равной полноте побеждает точка из доставки с большим `received_at` — это
|
|
||||||
делает свёртку по журналу детерминированной: проигрывание архива обязано дать
|
При **равной** полноте исход обязан быть детерминированным и не зависеть от
|
||||||
то же состояние, что приём в реальном времени.
|
порядка доставок. Первая редакция предписывала сравнение по `received_at` —
|
||||||
|
оно неисполнимо: у сохранённой точки нет провенанса, сравнивать не с чем. Хуже,
|
||||||
|
что четверть доставок несёт столкновения **внутри себя**, где `received_at`
|
||||||
|
вообще один. Поэтому исход определяется свойством самих значений (порядком
|
||||||
|
канонических форм), а не порядком событий.
|
||||||
|
|
||||||
|
Столкновение с различием содержимого оставляет след — `WARN` и счётчик. Иначе
|
||||||
|
допущение «меньше полей не значит новее», объявленное риском, не получит ни
|
||||||
|
одного наблюдения.
|
||||||
|
|
||||||
### Слой выводится по метрике внутри доставки, а не по доставке целиком
|
### Слой выводится по метрике внутри доставки, а не по доставке целиком
|
||||||
|
|
||||||
@@ -92,6 +155,13 @@
|
|||||||
Работающая формулировка: плотная метрика (≥10 точек) — сама по себе, редкая
|
Работающая формулировка: плотная метрика (≥10 точек) — сама по себе, редкая
|
||||||
наследует самый мелкий слой среди плотных.
|
наследует самый мелкий слой среди плотных.
|
||||||
|
|
||||||
|
Третий шаг — доставка без плотных метрик вовсе — наследует последний надёжно
|
||||||
|
выведенный слой той же автоматизации. Первая редакция заменила его заголовком,
|
||||||
|
и это была регрессия: измерено 2 такие доставки из 89, обе с заголовком
|
||||||
|
`Default`, который не означает режима. Наследовать нечего и заголовок
|
||||||
|
ненадёжен — точки не сохраняются, доставка ждёт пересборки; молчаливый `raw`
|
||||||
|
создал бы призрачный разрез, который поедет в каталог и в выбор слоя Read API.
|
||||||
|
|
||||||
### Сводка сна — отдельное имя метрики и фиксированный слой `day`
|
### Сводка сна — отдельное имя метрики и фиксированный слой `day`
|
||||||
|
|
||||||
Разводить схемы на имена приходится потому, что правило вывода слоя на суточной
|
Разводить схемы на имена приходится потому, что правило вывода слоя на суточной
|
||||||
@@ -120,10 +190,27 @@
|
|||||||
архива; паника в разборе перехватывается, доставка помечается
|
архива; паника в разборе перехватывается, доставка помечается
|
||||||
`parse_status=failed`, ответ остаётся `200`.
|
`parse_status=failed`, ответ остаётся `200`.
|
||||||
|
|
||||||
**Конкурентные доставки правят один час** → read-modify-write без блокировки
|
**Конкурентные доставки правят один час** → read-modify-write теряет точки, и
|
||||||
теряет точки. Три автоматизации шлют одновременно, и перекрытие часов — норма,
|
наивное «транзакция плюс повтор» **измеримо не работает**. Замер на
|
||||||
а не край. Запись объекта идёт в транзакции; при `SQLITE_BUSY` — повтор.
|
`modernc.org/sqlite` с DSN проекта, 4 горутины × 200 слияний в одну строку:
|
||||||
Проверяется тестом с параллельной записью в один `hour_utc`.
|
|
||||||
|
```
|
||||||
|
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`, чтобы драйвер не торчал наружу.
|
||||||
|
|
||||||
|
Тест обязан быть с настоящей конкуренцией и проверкой суммы: две горутины в
|
||||||
|
удачном порядке проходят и на сломанной реализации.
|
||||||
|
|
||||||
**Правило полноты ошибочно для метрики, где меньше полей значит новее** →
|
**Правило полноты ошибочно для метрики, где меньше полей значит новее** →
|
||||||
таких в потоке не наблюдалось, но допущение не доказано. Помечается как
|
таких в потоке не наблюдалось, но допущение не доказано. Помечается как
|
||||||
|
|||||||
@@ -27,9 +27,10 @@
|
|||||||
формы — детектор изменений, а не ключ.
|
формы — детектор изменений, а не ключ.
|
||||||
- `sleep_analysis` разводится на два имени: поэпизодное и суточную сводку —
|
- `sleep_analysis` разводится на два имени: поэпизодное и суточную сводку —
|
||||||
под одним именем HAE шлёт две несовместимые схемы.
|
под одним именем HAE шлёт две несовместимые схемы.
|
||||||
- Три формата времени на входе: локальное со смещением, RFC 3339 Z,
|
- Метка точки разбирается из локального времени со смещением и хранится в UTC
|
||||||
Unix-эпоха внутри `heartbeatSeries`. В хранилище — UTC RFC 3339 плюс офсет
|
плюс офсет исходной зоны. Эпоха внутри `heartbeatSeries` **не разбирается** —
|
||||||
исходной зоны.
|
серия проходит исходными байтами; RFC 3339 встречается только в
|
||||||
|
`stateOfMind` и нормируется вместе с ним, отдельной задачей.
|
||||||
- Разбор не влияет на код ответа приёма: непонятое содержимое по-прежнему
|
- Разбор не влияет на код ответа приёма: непонятое содержимое по-прежнему
|
||||||
даёт 200, исход разбора виден в `delivery.parse_status` и в логе.
|
даёт 200, исход разбора виден в `delivery.parse_status` и в логе.
|
||||||
|
|
||||||
@@ -42,7 +43,13 @@
|
|||||||
Сходимость на них проверяется скриптом поверх архива, а не командой сервиса;
|
Сходимость на них проверяется скриптом поверх архива, а не командой сервиса;
|
||||||
- **словарь категориальных значений** (переведённые строки → коды HealthKit) —
|
- **словарь категориальных значений** (переведённые строки → коды HealthKit) —
|
||||||
отдельная задача; пока строка хранится дословно и без кода рядом;
|
отдельная задача; пока строка хранится дословно и без кода рядом;
|
||||||
- **род агрегации и каталог разрезов** — отдельная задача, она следующая.
|
- **род агрегации и каталог разрезов** — отдельная задача, она следующая;
|
||||||
|
- **хранение поэпизодного `sleep_analysis`** — вынуто ревью дизайна блокером
|
||||||
|
`identichnost-epizodnyh-metrik`: координатный ключ его схлопывает (измерено:
|
||||||
|
31 координата в 21 доставке из 89 задвоена внутри одной доставки, где
|
||||||
|
тай-брейк по времени приёма неприменим в принципе). Точки разбираются и
|
||||||
|
считаются, но не сохраняются; тела в архиве, подберёт пересборка.
|
||||||
|
Суточной сводки это не касается.
|
||||||
|
|
||||||
## Capabilities
|
## Capabilities
|
||||||
|
|
||||||
@@ -63,7 +70,9 @@
|
|||||||
|
|
||||||
## Impact
|
## Impact
|
||||||
|
|
||||||
- Новые пакеты `internal/hae` (разбор) и расширение `internal/store` (объекты).
|
- Новые пакеты `internal/hae` (разбор), `internal/canon` (каноническая форма,
|
||||||
|
полнота, хеш — общий дом для разбора и хранения) и расширение
|
||||||
|
`internal/store` (объекты).
|
||||||
- Миграция `internal/store/migrations/00003_bucket.sql` — первая миграция после
|
- Миграция `internal/store/migrations/00003_bucket.sql` — первая миграция после
|
||||||
приёма; вместе с ней заводится `docs/database.md` (ER-схема), которую требует
|
приёма; вместе с ней заводится `docs/database.md` (ER-схема), которую требует
|
||||||
шаг гейта `er-schema`.
|
шаг гейта `er-schema`.
|
||||||
|
|||||||
@@ -6,14 +6,20 @@
|
|||||||
в точки. Точка несёт имя метрики, единицы, слой, метку времени и содержимое в
|
в точки. Точка несёт имя метрики, единицы, слой, метку времени и содержимое в
|
||||||
том виде, в каком его прислал HAE.
|
том виде, в каком его прислал HAE.
|
||||||
|
|
||||||
Незнакомое поле внутри точки MUST сохраняться, а не отбрасываться: сырой архив
|
Хранимая форма точки — **исходные байты**, как они пришли в теле доставки.
|
||||||
недолговечен, и отброшенное поле теряется безвозвратно.
|
Система MUST NOT пересобирать содержимое повторной сериализацией разобранных
|
||||||
|
значений: обход через `map[string]any` теряет литерал (`1.0` становится `1`,
|
||||||
|
целые больше 2^53 сдвигаются, невалидный UTF-8 заменяется на U+FFFD), и потеря
|
||||||
|
не видна тестам на фикстурах — они сравнивают разобранное с разобранным.
|
||||||
|
|
||||||
|
Отсюда же следует, что «служебных» полей у точки нет: отбрасывать нечего,
|
||||||
|
нормализованное время добавляется рядом с исходным содержимым, а не вместо.
|
||||||
|
|
||||||
#### Scenario: Метрика с точками разбирается в точки
|
#### Scenario: Метрика с точками разбирается в точки
|
||||||
|
|
||||||
- **WHEN** тело содержит `data.metrics[]` с непустым `data[]`
|
- **WHEN** тело содержит `data.metrics[]` с непустым `data[]`
|
||||||
- **THEN** каждая точка с непустым `date` становится точкой хранилища
|
- **THEN** каждая точка с непустым `date` становится точкой хранилища
|
||||||
- **AND** все поля точки, кроме служебных, сохраняются дословно
|
- **AND** её содержимое сохраняется исходными байтами, без пересборки
|
||||||
|
|
||||||
#### Scenario: Незнакомая метрика не ломает разбор
|
#### Scenario: Незнакомая метрика не ломает разбор
|
||||||
|
|
||||||
@@ -34,8 +40,15 @@
|
|||||||
заголовка доставки. Заголовок `automation-aggregation` непригоден: значение
|
заголовка доставки. Заголовок `automation-aggregation` непригоден: значение
|
||||||
`Default` соответствует трём разным режимам выгрузки.
|
`Default` соответствует трём разным режимам выгрузки.
|
||||||
|
|
||||||
Слои: `raw` (метка на произвольной секунде), `minute` (секунды нулевые),
|
Выводимые слои: `raw` (метка на произвольной секунде), `minute` (секунды
|
||||||
`hour` (секунды и минуты нулевые).
|
нулевые), `hour` (секунды и минуты нулевые). Слой `day` в перечисление входит,
|
||||||
|
но **не выводится** — он назначается схемам с фиксированной гранулярностью
|
||||||
|
(см. «Разделение схем под одним именем метрики»).
|
||||||
|
|
||||||
|
Разделение схем под одним именем выполняется **до** вывода слоя, и точки с
|
||||||
|
назначенным слоем в определении преобладающего слоя доставки не участвуют:
|
||||||
|
суточных сводок сна бывает больше порога плотности, и их полуночные метки
|
||||||
|
иначе назначили бы всей доставке слой `hour`.
|
||||||
|
|
||||||
Классификация MUST быть **по метрике внутри доставки**, а не по доставке
|
Классификация MUST быть **по метрике внутри доставки**, а не по доставке
|
||||||
целиком: при перенастройке автоматизации приезжают смешанные доставки, и
|
целиком: при перенастройке автоматизации приезжают смешанные доставки, и
|
||||||
@@ -56,23 +69,54 @@
|
|||||||
#### Scenario: В доставке нет плотных метрик
|
#### Scenario: В доставке нет плотных метрик
|
||||||
|
|
||||||
- **WHEN** ни у одной метрики доставки нет десяти точек
|
- **WHEN** ни у одной метрики доставки нет десяти точек
|
||||||
- **THEN** слой берётся из заголовка `automation-aggregation`
|
- **THEN** слой наследуется от последнего надёжно выведенного слоя той же
|
||||||
(`Minutes` → `minute`, `Hours` → `hour`, иначе `raw`)
|
автоматизации (`automation-id`)
|
||||||
|
- **AND** если наследовать нечего, слой берётся из **надёжного** заголовка
|
||||||
|
(`Minutes` → `minute`, `Hours` → `hour`)
|
||||||
|
|
||||||
#### Scenario: Выведенный слой расходится с заголовком
|
#### Scenario: Наследовать нечего и заголовок ненадёжен
|
||||||
|
|
||||||
- **WHEN** выведенный слой не совпадает с тем, что объявляет заголовок доставки
|
- **WHEN** плотных метрик нет, предыдущего слоя автоматизации нет, а заголовок
|
||||||
|
равен `Default`
|
||||||
|
- **THEN** точки доставки не сохраняются, а исход учитывается счётчиком и
|
||||||
|
записью `WARN`
|
||||||
|
- **AND** тело остаётся в архиве, откуда доставку подберёт пересборка
|
||||||
|
|
||||||
|
Молчаливый выбор `raw` в этой ветке недопустим: заголовок `Default` наблюдался
|
||||||
|
одновременно у посекундного, минутного и часового режимов, поэтому он не
|
||||||
|
доказывает ничего, а призрачный `raw`-разрез попадёт в каталог и в правило
|
||||||
|
Read API «самый мелкий слой, покрывающий диапазон».
|
||||||
|
|
||||||
|
#### Scenario: Выведенный слой расходится с надёжным заголовком
|
||||||
|
|
||||||
|
- **WHEN** выведенный слой не совпадает с заголовком `Minutes` или `Hours`
|
||||||
- **THEN** система пишет запись уровня `WARN`
|
- **THEN** система пишет запись уровня `WARN`
|
||||||
- **AND** сохраняет точки по выведенному слою, а не по заголовку
|
- **AND** сохраняет точки по выведенному слою, а не по заголовку
|
||||||
|
|
||||||
|
#### Scenario: Заголовок `Default` в сравнении не участвует
|
||||||
|
|
||||||
|
- **WHEN** заголовок доставки равен `Default`
|
||||||
|
- **THEN** расхождение не фиксируется и `WARN` не пишется
|
||||||
|
|
||||||
|
Иначе сигнал утонул бы в собственном шуме: `Default` не означает режима, и
|
||||||
|
сравнение с ним давало бы `WARN` на каждой доставке потока в пять минут.
|
||||||
|
|
||||||
### Requirement: Разбор форматов времени
|
### Requirement: Разбор форматов времени
|
||||||
|
|
||||||
Система SHALL понимать три формата времени, встречающиеся в теле доставки, и
|
Система SHALL разбирать метку точки формата `2026-07-31 21:03:51 +0300` и
|
||||||
приводить их к UTC, сохраняя офсет исходной зоны.
|
приводить её к UTC, сохраняя офсет исходной зоны. В секции `data.metrics`
|
||||||
|
других форматов меток не встречается.
|
||||||
|
|
||||||
- `2026-07-31 21:03:51 +0300` — метрики и тренировки;
|
Unix-эпоха дробным числом (`1785446196.4132624`) встречается **внутри**
|
||||||
- `2026-07-31T18:03:51Z` — RFC 3339 в UTC;
|
`heartbeatSeries` и меткой точки не является. Система MUST NOT преобразовывать
|
||||||
- `1785446196.4132624` — Unix-эпоха дробным числом внутри `heartbeatSeries`.
|
её: элементы серии проходят как исходные байты. Преобразование во `time.Unix`
|
||||||
|
и обратно не гарантирует дословности, а серия составляет 93% объёма метрики
|
||||||
|
`heart_rate_variability`.
|
||||||
|
|
||||||
|
RFC 3339 (`2026-07-31T18:03:51Z`) в этой дельте не нормируется: он встречается
|
||||||
|
только в `data.stateOfMind`, которая выведена из scope. Требование к нему
|
||||||
|
появится вместе с задачей про секции с собственными `id` — вместе с данными,
|
||||||
|
на которых его можно проверить.
|
||||||
|
|
||||||
#### Scenario: Локальное время со смещением
|
#### Scenario: Локальное время со смещением
|
||||||
|
|
||||||
@@ -82,8 +126,9 @@
|
|||||||
#### Scenario: Время внутри серии ударов
|
#### Scenario: Время внутри серии ударов
|
||||||
|
|
||||||
- **WHEN** точка метрики `heart_rate_variability` содержит `heartbeatSeries`
|
- **WHEN** точка метрики `heart_rate_variability` содержит `heartbeatSeries`
|
||||||
- **THEN** элементы серии сохраняются дословно вместе с их эпохой
|
- **THEN** элементы серии сохраняются исходными байтами вместе с их эпохой
|
||||||
- **AND** серия не разворачивается в отдельные точки
|
- **AND** серия не разворачивается в отдельные точки
|
||||||
|
- **AND** эпоха внутри серии не разбирается и не преобразуется
|
||||||
|
|
||||||
### Requirement: Разделение схем под одним именем метрики
|
### Requirement: Разделение схем под одним именем метрики
|
||||||
|
|
||||||
@@ -108,9 +153,16 @@ Export шлёт под одним именем, чтобы одно имя оз
|
|||||||
|
|
||||||
### Requirement: Канонизация содержимого
|
### Requirement: Канонизация содержимого
|
||||||
|
|
||||||
Система SHALL приводить содержимое точки к канонической форме перед сравнением
|
Система SHALL вычислять каноническую форму содержимого точки для сравнения и
|
||||||
и хешированием: рекурсивная сортировка ключей и округление чисел до двенадцати
|
хеширования: сортировка ключей и округление чисел до двенадцати значащих цифр.
|
||||||
значащих цифр.
|
|
||||||
|
Каноническая форма существует **только в момент вычисления хеша** и хранимую
|
||||||
|
форму не заменяет никогда: хранится исходные байты (см. «Разбор секции
|
||||||
|
метрик»). Числа при канонизации читаются литералом, а не через `float64`, —
|
||||||
|
иначе округление применится к уже испорченному значению.
|
||||||
|
|
||||||
|
Сортировка ключей — свойство `encoding/json`, своей реализации не требует.
|
||||||
|
Собственным остаётся только округление.
|
||||||
|
|
||||||
Без округления сравнение бесполезно: 63% повторно приехавших точек различались
|
Без округления сравнение бесполезно: 63% повторно приехавших точек различались
|
||||||
последним разрядом double при одинаковом измерении.
|
последним разрядом double при одинаковом измерении.
|
||||||
|
|||||||
@@ -21,15 +21,42 @@
|
|||||||
- **WHEN** точка с теми же координатами приезжает с другой строкой `source`
|
- **WHEN** точка с теми же координатами приезжает с другой строкой `source`
|
||||||
- **THEN** она остаётся одной точкой, а не превращается в две
|
- **THEN** она остаётся одной точкой, а не превращается в две
|
||||||
|
|
||||||
|
### Requirement: Эпизодные схемы не сохраняются до решения об идентичности
|
||||||
|
|
||||||
|
Система MUST NOT сохранять точки схем, у которых метка времени не уникальна, —
|
||||||
|
пока модель их идентичности не определена. Такие точки разбираются и
|
||||||
|
учитываются счётчиком, но в объекты не пишутся.
|
||||||
|
|
||||||
|
Известная такая схема одна: поэпизодный `sleep_analysis`. Измерено: 31
|
||||||
|
координата в 21 доставке из 89 задвоена **внутри одной доставки**, где
|
||||||
|
`received_at` общий и тай-брейк по нему неприменим в принципе. Сохранять их по
|
||||||
|
правилу, о котором известно, что оно теряет, хуже, чем не сохранять: тела
|
||||||
|
остаются в архиве, и после решения их подберёт пересборка.
|
||||||
|
|
||||||
|
Развилка вынесена блокером `identichnost-epizodnyh-metrik`. Суточной сводки
|
||||||
|
(`sleep_analysis_summary`) ограничение не касается — у неё метка на полуночи
|
||||||
|
уникальна.
|
||||||
|
|
||||||
|
#### Scenario: Поэпизодная запись сна не попадает в объект
|
||||||
|
|
||||||
|
- **WHEN** разобрана точка метрики `sleep_analysis` поэпизодной схемы
|
||||||
|
- **THEN** она не сохраняется в часовой объект
|
||||||
|
- **AND** факт учитывается счётчиком в итоге разбора доставки
|
||||||
|
|
||||||
### Requirement: Разрешение столкновений по полноте
|
### Requirement: Разрешение столкновений по полноте
|
||||||
|
|
||||||
Когда по одним координатам приходят разные содержимые, система SHALL оставлять
|
Когда по одним координатам приходят разные содержимые, система SHALL оставлять
|
||||||
**более полную** точку — ту, у которой больше значащих полей, — а не последнюю
|
**более полную** точку — ту, у которой больше значащих полей, — а не последнюю
|
||||||
пришедшую. Иначе бедная доставка стирает `start`/`end` у богатой.
|
пришедшую. Иначе бедная доставка стирает `start`/`end` у богатой.
|
||||||
|
|
||||||
Если полнота равна, а значения различаются, исход определяет порядок
|
Если полнота равна, а значения различаются, исход MUST быть детерминированным
|
||||||
воспроизведения, и он MUST быть по `received_at` доставки: свёртка по журналу
|
и не зависеть от порядка, в котором доставки дошли до хранилища: свёртка по
|
||||||
обязана давать то же состояние, что приём в реальном времени.
|
журналу обязана давать то же состояние, что приём в реальном времени.
|
||||||
|
|
||||||
|
Сравнение по `received_at` для этого не годится: у сохранённой точки нет
|
||||||
|
провенанса — ни времени приёма, ни идентификатора доставки, — и сравнивать
|
||||||
|
не с чем. Детерминизм обеспечивается свойством самих значений (например,
|
||||||
|
порядком канонических форм), а не порядком событий.
|
||||||
|
|
||||||
#### Scenario: Бедная точка не стирает поля богатой
|
#### Scenario: Бедная точка не стирает поля богатой
|
||||||
|
|
||||||
@@ -41,7 +68,19 @@
|
|||||||
|
|
||||||
- **WHEN** по одним координатам приходят две одинаково полные точки с разными
|
- **WHEN** по одним координатам приходят две одинаково полные точки с разными
|
||||||
значениями
|
значениями
|
||||||
- **THEN** побеждает точка из доставки с большим `received_at`
|
- **THEN** исход определяется детерминированно и не зависит от порядка
|
||||||
|
воспроизведения доставок
|
||||||
|
|
||||||
|
#### Scenario: Столкновение с различием содержимого оставляет след
|
||||||
|
|
||||||
|
- **WHEN** по одним координатам сохраняется точка, каноническая форма которой
|
||||||
|
отличается от уже сохранённой
|
||||||
|
- **THEN** система пишет запись уровня `WARN` без значений точки
|
||||||
|
- **AND** увеличивает счётчик перезаписей в итоге разбора доставки
|
||||||
|
|
||||||
|
Без этого следа допущение «меньше полей не значит новее» не получит ни одного
|
||||||
|
наблюдения, а отказ правила будет неотличим от нормальной работы до сверки с
|
||||||
|
экспортом Apple — то есть месяцами.
|
||||||
|
|
||||||
### Requirement: Хранение часовыми объектами
|
### Requirement: Хранение часовыми объектами
|
||||||
|
|
||||||
@@ -49,6 +88,17 @@
|
|||||||
`метрика + слой + час (UTC)`. Содержимое объекта — сжатый gzip блоб; точки
|
`метрика + слой + час (UTC)`. Содержимое объекта — сжатый gzip блоб; точки
|
||||||
внутри упорядочены по времени.
|
внутри упорядочены по времени.
|
||||||
|
|
||||||
|
Объект SHALL нести **единицы измерения** метрики. Внутри точки их нет — они
|
||||||
|
живут на уровне метрики (проверено: поле `units` не встретилось ни в одной
|
||||||
|
точке за 89 доставок), поэтому дословное хранение точек их не сохраняет. Без
|
||||||
|
колонки единицы восстановимы только из архива, а для метрик, переставших
|
||||||
|
приходить, — теряются навсегда.
|
||||||
|
|
||||||
|
Объект SHALL нести границы содержимого (первая и последняя метка) и
|
||||||
|
идентификатор доставки, создавшей его. Первое нужно каталогу разрезов, чтобы
|
||||||
|
не разжимать каждый блоб ради диапазона; второе — провенанс для разбора
|
||||||
|
слияний.
|
||||||
|
|
||||||
Запись — чтение объекта, слияние точек, запись обратно. Точки из объекта
|
Запись — чтение объекта, слияние точек, запись обратно. Точки из объекта
|
||||||
MUST NOT удаляться.
|
MUST NOT удаляться.
|
||||||
|
|
||||||
@@ -78,8 +128,14 @@ MUST NOT удаляться.
|
|||||||
|
|
||||||
### Requirement: Признак запечатанного часа
|
### Requirement: Признак запечатанного часа
|
||||||
|
|
||||||
Система SHALL отмечать признаком `sealed` часы, которые уже не должны
|
Система SHALL хранить признак `sealed` у часового объекта и SHALL реагировать
|
||||||
меняться. Изменение запечатанного объекта — не отказ, а сигнал.
|
на изменение запечатанного объекта сигналом, а не отказом.
|
||||||
|
|
||||||
|
Правило перевода часа в `sealed` в этой дельте **не определяется**: порог
|
||||||
|
глубины досчёта ставится по наблюдениям, которых пока нет (наблюдалось до
|
||||||
|
22 минут). До появления правила признак остаётся невыставленным, и сценарий
|
||||||
|
ниже проверяется только явной установкой в тесте — это осознанная граница, а
|
||||||
|
не упущение.
|
||||||
|
|
||||||
#### Scenario: Изменение запечатанного часа
|
#### Scenario: Изменение запечатанного часа
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +1,73 @@
|
|||||||
## 1. Фикстуры и схема
|
## 1. Фикстуры и схема
|
||||||
|
|
||||||
- [ ] 1.1 Скрипт `tmp/research/fixtures.py`: собирает фикстуры из `data/raw`, вычищая измеренные значения и сохраняя порядок ключей, точность чисел, неразрывные пробелы и форматы времени
|
- [ ] 1.1 Скрипт `tmp/research/fixtures.py`: собирает фикстуры из `data/raw`, вычищая измеренные значения и сохраняя порядок ключей, точность чисел, неразрывные пробелы и форматы времени
|
||||||
- [ ] 1.2 Набор `internal/hae/testdata`: минутная доставка, посекундная, часовая, смешанная (перенастройка автоматизации), обе схемы `sleep_analysis`, точка с `heartbeatSeries`
|
- [ ] 1.2 Набор `internal/hae/testdata`: минутная доставка, посекундная, часовая, смешанная (перенастройка автоматизации), обе схемы `sleep_analysis`, точка с `heartbeatSeries`, доставка без плотных метрик с заголовком `Default`
|
||||||
- [ ] 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.3 Рукотворная фикстура с **выдуманным полем точки** — скрипт из архива её не породит, а без неё требование «незнакомое поле сохраняется» останется без теста
|
||||||
- [ ] 1.4 `docs/database.md` — ER-схема с `delivery` и `bucket` (шаг гейта `er-schema` требует её при изменении миграций)
|
- [ ] 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)`
|
||||||
|
- [ ] 1.5 `docs/database.md` — ER-схема с `delivery` и `bucket` (шаг гейта `er-schema` требует её при изменении миграций)
|
||||||
|
|
||||||
## 2. Разбор — пакет `internal/hae`
|
## 2. Канонизация — общий дом
|
||||||
|
|
||||||
- [ ] 2.1 Разбор трёх форматов времени в UTC с сохранением офсета исходной зоны; неразобранная метка — не паника, а пропуск точки со счётчиком
|
- [ ] 2.1 Пакет `internal/canon`: каноническая форма (числа читаются литералом через `json.Number`, округление до 12 значащих цифр — именованная константа со ссылкой на находку 30), полнота точки, хеш
|
||||||
- [ ] 2.2 Вывод слоя: выравнивание меток, плотная метрика (≥10 точек) сама, редкая наследует преобладающий слой, при отсутствии плотных — заголовок доставки
|
- [ ] 2.2 Сортировку ключей **не писать** — её делает `encoding/json`
|
||||||
- [ ] 2.3 Расхождение выведенного слоя с заголовком доставки — запись `WARN` без значений точек
|
- [ ] 2.3 Тест канонизации на настоящих парах чисел из находки 30: все три схлопываются
|
||||||
- [ ] 2.4 Разделение `sleep_analysis` на поэпизодную и `sleep_analysis_summary` со слоем `day`
|
- [ ] 2.4 Тест: значение с `1.0`, целым больше 2^53 и невалидным UTF-8 переживает хранение дословно
|
||||||
- [ ] 2.5 Канонизация: рекурсивная сортировка ключей, округление чисел до 12 значащих цифр
|
|
||||||
- [ ] 2.6 `hae.Parse` возвращает точки со слоем, метрикой, меткой и содержимым как пришло; незнакомые поля и метрики сохраняются
|
|
||||||
- [ ] 2.7 Тесты разбора на фикстурах из 1.2, включая смешанную доставку и обе схемы сна
|
|
||||||
|
|
||||||
## 3. Хранение — часовые объекты
|
## 3. Разбор — пакет `internal/hae`
|
||||||
|
|
||||||
- [ ] 3.1 Модель точки и объекта в `internal/store`; сериализация содержимого в gzip-BLOB, точки внутри упорядочены по времени
|
- [ ] 3.1 Декодирование конверта в структуру с `Data []json.RawMessage`; точка декодируется по одной. Тест удержания кучи на теле в десятки МиБ
|
||||||
- [ ] 3.2 Слияние: координатный ключ `метрика + слой + метка`, победа более полной точки, при равной полноте — по `received_at`
|
- [ ] 3.2 Разбор метки `2006-01-02 15:04:05 -0700` в UTC с сохранением офсета; неразобранная метка — пропуск точки со счётчиком, не паника
|
||||||
- [ ] 3.3 Хеш канонической формы объекта как детектор изменений: совпал — записи нет
|
- [ ] 3.3 Эпоха внутри `heartbeatSeries` **не разбирается**: элементы проходят исходными байтами
|
||||||
- [ ] 3.4 Запись объекта в транзакции; повтор при `SQLITE_BUSY`
|
- [ ] 3.4 Вывод слоя: плотная метрика (≥10 точек) сама, редкая наследует преобладающий, доставка без плотных — наследует последний слой автоматизации по `automation-id`
|
||||||
- [ ] 3.5 Признак `sealed`: изменение запечатанного часа пишет `WARN` и всё равно сохраняет
|
- [ ] 3.5 Наследовать нечего и заголовок `Default` — точки не сохраняются, `WARN` и счётчик
|
||||||
- [ ] 3.6 Тест конкурентной записи в один `hour_utc`: точки обеих сторон на месте
|
- [ ] 3.6 `WARN` о расхождении слоя — только против `Minutes`/`Hours`; `Default` в сравнении не участвует
|
||||||
- [ ] 3.7 Тест идемпотентности: повторное слияние того же набора не меняет ни содержимое, ни хеш
|
- [ ] 3.7 Разделение `sleep_analysis` на поэпизодную и `sleep_analysis_summary` со слоем `day`; выполняется **до** вывода слоя, точки с назначенным слоем в голосовании не участвуют
|
||||||
|
- [ ] 3.8 `recover` **внутри** `hae.Parse` — превращает панику разбора в ошибку пакета; паника из `store` наверх не перехватывается
|
||||||
|
- [ ] 3.9 Контракт: `error` ненулевая только когда точек нет вовсе; частичные исходы — счётчиками в результате
|
||||||
|
- [ ] 3.10 `FuzzParse` и таблица враждебных входов: усечённое тело, `null` вместо объекта, массив вместо `data`, число вместо строки даты
|
||||||
|
- [ ] 3.11 Тесты разбора на фикстурах 1.2–1.3, включая смешанную доставку и обе схемы сна
|
||||||
|
|
||||||
## 4. Сшивка с приёмом
|
## 4. Хранение — часовые объекты
|
||||||
|
|
||||||
- [ ] 4.1 `internal/ingest` вызывает разбор после записи тела в архив и строки `delivery`
|
- [ ] 4.1 Модель объекта в `internal/store`; содержимое — исходные байты точек, gzip-BLOB, точки упорядочены по времени
|
||||||
- [ ] 4.2 Отказ и паника разбора не меняют код ответа: `200`, `parse_status=failed`, запись лога уровня `ERROR` без значений
|
- [ ] 4.2 Слияние: координатный ключ, победа более полной точки, при равной полноте — детерминированный исход по порядку канонических форм
|
||||||
- [ ] 4.3 Единственный логирующий чекпоинт на границе: счётчики метрик, точек и объектов, идентификатор доставки; ни значений, ни имён устройств
|
- [ ] 4.3 Столкновение с различием канонической формы — `WARN` без значений и счётчик перезаписей
|
||||||
- [ ] 4.4 Тест приёма: битый JSON — 400, непонятое содержимое — 200 с `parse_status=failed`
|
- [ ] 4.4 Поэпизодный `sleep_analysis` **не сохраняется** (блокер `identichnost-epizodnyh-metrik`): точки считаются, в объекты не пишутся
|
||||||
|
- [ ] 4.5 Хеш объекта как детектор изменений: совпал — записи нет
|
||||||
|
- [ ] 4.6 `_txlock=immediate` в DSN; повтор оборачивает **всю тройку** чтение-слияние-запись; путь «хеш совпал» — под `TxOptions{ReadOnly: true}`
|
||||||
|
- [ ] 4.7 Распознавание занятости — `errors.As` на `*sqlite.Error`, коды 5 и 517, обёрнуто в `store`
|
||||||
|
- [ ] 4.8 Тест конкурентной записи: N горутин × M слияний в один `hour_utc`, проверка **суммы** точек, под `-race`
|
||||||
|
- [ ] 4.9 Тест идемпотентности: повторное слияние того же набора не меняет ни содержимое, ни хеш
|
||||||
|
- [ ] 4.10 Тесты сценариев слияния: «бедная точка не стирает поля богатой», «смена `source` не создаёт вторую точку»
|
||||||
|
|
||||||
## 5. Сходимость на реальных данных
|
## 5. Сшивка с приёмом
|
||||||
|
|
||||||
- [ ] 5.1 Скрипт `tmp/research/verify_buckets.py`: прогоняет архив через разбор и сверяет суммы по часовому слою с прежней проверкой из разведки
|
- [ ] 5.1 Свёртка вызывается по идентификатору доставки, тело читается из архива — один код с будущей пересборкой
|
||||||
- [ ] 5.2 Прогон на всех накопленных доставках: расхождений по накопительным метрикам нет
|
- [ ] 5.2 Работа после записи в архив — на `context.WithoutCancel` с собственным дедлайном: обрыв соединения не рвёт запись объектов
|
||||||
- [ ] 5.3 `task gate` зелёный; `task restart` поднимает сервис, новая доставка с телефона разбирается
|
- [ ] 5.3 Отказ разбора не меняет код ответа: `200`, `parse_status=failed`, запись `ERROR` без значений
|
||||||
|
- [ ] 5.4 Единственный логирующий чекпоинт на границе: счётчики метрик, точек, объектов, пропусков, перезаписей; идентификатор доставки; ни значений, ни имён устройств
|
||||||
|
- [ ] 5.5 Тест приёма: битый JSON — 400, непонятое содержимое — 200 с `parse_status=failed`
|
||||||
|
|
||||||
## 6. Приёмочные критерии ревью дизайна
|
## 6. Сходимость на реальных данных
|
||||||
|
|
||||||
<!-- Заполняется рубрикой из healthlog-review-rubric на шаге ревью дизайна -->
|
- [ ] 6.1 Скрипт `tmp/research/verify_buckets.py`: прогоняет архив через разбор и сверяет суммы по часовому слою с проверкой из разведки
|
||||||
|
- [ ] 6.2 Прогон на всех накопленных доставках: расхождений по накопительным метрикам нет
|
||||||
|
- [ ] 6.3 `task gate` зелёный; `task restart` поднимает сервис, новая доставка с телефона разбирается
|
||||||
|
|
||||||
|
## 7. Приёмочные критерии (рубрика ревью дизайна)
|
||||||
|
|
||||||
|
Порождена проходом `healthlog-review-rubric` **до** чтения предложения. Каждый
|
||||||
|
пункт проверяем: понятно, каким тестом его провалить.
|
||||||
|
|
||||||
|
- [ ] 7.1 Отсутствие паники на произвольном входе — усечённый JSON, `null` вместо объекта, массив вместо объекта, число вместо строки даты
|
||||||
|
- [ ] 7.2 Незнакомое поле точки переживает round-trip дословно
|
||||||
|
- [ ] 7.3 Идемпотентность повторной записи, включая переставленный порядок ключей и точек
|
||||||
|
- [ ] 7.4 Различие не теряется молча: перезапись и изменение запечатанного часа оставляют след
|
||||||
|
- [ ] 7.5 Конкурентное слияние того же часа не теряет точки — под `-race`, с проверкой суммы
|
||||||
|
- [ ] 7.6 Отмена посреди слияния не оставляет половинчатого состояния: объект либо прежний, либо полный
|
||||||
|
- [ ] 7.7 Граница размера входа явная; вход в сотни мегабайт не кладёт процесс по памяти
|
||||||
|
- [ ] 7.8 Ошибки различимы по типу, а не по тексту (`errors.Is`/`errors.As`)
|
||||||
|
- [ ] 7.9 Частично непонятный пакет имеет явную судьбу: что сохранено, что отброшено — видно в счётчиках
|
||||||
|
- [ ] 7.10 Время нормализовано без потери зоны
|
||||||
|
- [ ] 7.11 Слой выводится из данных, а не из заголовка; неопределимый слой имеет явную судьбу
|
||||||
|
- [ ] 7.12 Парсер детерминирован и чист: ни `time.Now`, ни генерации id; два вызова на одном входе равны
|
||||||
|
|||||||
Reference in New Issue
Block a user