- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
объявляет слой, измеренный род, его применимость к отданному ряду и границу
окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
записи: дословность содержимого точки иначе не удерживается, а оборванное
тело уходило под видом успешного `200`
1820 lines
163 KiB
Markdown
1820 lines
163 KiB
Markdown
# Архитектура
|
||
|
||
Обзор: как сложено и где что работает. **Поведение системы здесь не
|
||
описывается** — его нормативный дом [`openspec/specs/`](../openspec/specs).
|
||
Разделы, помеченные `<!-- канон: поведение → … -->`, ещё не разнесены:
|
||
это долг переезда на канон 2026-08-03, он закрывается порциями по ходу
|
||
задач и гейт от него не краснеет.
|
||
|
||
## Назначение
|
||
|
||
healthlog принимает выгрузки Apple Health из приложения Health Auto Export
|
||
(далее HAE) и родного экспорта Apple Health, хранит их и отдаёт другим
|
||
приложениям — в том числе агентам, через MCP. Он не переименовывает поля и не
|
||
интерпретирует значения; агрегаты считает только в ответе на запрос и только
|
||
там, где род метрики измерен, а не угадан.
|
||
|
||
## Принципы
|
||
|
||
- **Один статический бинарь** (`CGO_ENABLED=0`), доставка — docker-образом.
|
||
- **Точки хранятся дословно.** Часовой объект держит точки ровно в том виде,
|
||
в каком их прислал HAE — без переименований, пересчётов и отбрасывания
|
||
незнакомых полей. Поэтому хранилище само по себе является полной копией
|
||
данных, а не производной выжимкой.
|
||
- **Хранилище — свёртка по журналу, а не единственная копия.** Экспорт Apple
|
||
это снапшот всей истории, доставки HAE после его даты — события поверх
|
||
снапшота. Состояние всегда пересобираемо: `import(экспорт) + replay(доставки)`.
|
||
Отсюда срок жизни сырого архива — до следующего проверенного экспорта, а не
|
||
произвольные две недели. Исключение названо вслух: `stateOfMind` в экспорт не
|
||
попадает, см. «Хранилище».
|
||
- **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор
|
||
(см. «Приём»).
|
||
- **Ничего не теряем молча.** Идентичность — устойчивые координаты
|
||
(`метрика + слой + начало + конец`; у точки-измерения конец равен началу);
|
||
`source` в ключ не входит, он
|
||
нестабилен. Хеш канонизированного содержимого остаётся детектором изменений,
|
||
чтобы не писать зря. При столкновении выигрывает более полная точка, а при
|
||
равной полноте — стоящая **позже в журнале**: бедная доставка не должна
|
||
стирать поля у богатой, но и устаревшее значение не должно пережить свой
|
||
досчёт. Полнота — **множество** ключей с непустым значением, а не их число
|
||
(см. «Разрешение столкновений»).
|
||
- **Дыры закрываются сами.** Данные приходят несколькими проходами разной
|
||
глубины, поэтому пропущенная доставка не оставляет постоянного пробела —
|
||
см. «Модель синхронизации».
|
||
- **Форма Apple не транслируется.** Значения отдаются такими, какими пришли;
|
||
нормализовано только время. Единственное добавление — стабильный код рядом
|
||
с переведённой строкой (см. «Категориальные значения»): он приписывается, а
|
||
не подменяет.
|
||
- **Своей агрегации в хранении нет — есть слои.** Метрика лежит в тех
|
||
разрезах подробности, в которых пришла (`sample`/`raw`/`minute`/`hour`);
|
||
переагрегирования при записи не происходит никогда.
|
||
- **Агрегация в ответе — только измеренная.** Read API умеет свести метрику к
|
||
запрошенной сетке, но род свёртки (сумма или среднее) выведен сверкой слоёв
|
||
между собой, а не проставлен вручную. Где род неизвестен, агрегация не
|
||
предлагается: отдаются значения как есть.
|
||
- **Минимум компонентов** — один процесс, SQLite, файлы. Без очередей и
|
||
внешних зависимостей.
|
||
|
||
## Формат Health Auto Export
|
||
|
||
<!-- канон: поведение → openspec/specs/parsing -->
|
||
|
||
Документация формата скудная: [help.healthyapps.dev](https://help.healthyapps.dev/en/health-auto-export/automations/rest-api/)
|
||
и [wiki Lybron/health-auto-export](https://github.com/Lybron/health-auto-export/wiki/API-Export---JSON-Format).
|
||
Ниже — то, на что мы опираемся; всё остальное уточняем по реальным пакетам.
|
||
|
||
Автоматизация HAE шлёт **POST** с JSON-телом и своими заголовками:
|
||
`automation-name`, `automation-id`, `automation-aggregation`,
|
||
`automation-period`, `session-id`. Свои заголовки (токен) добавляются в
|
||
настройках автоматизации. Большой экспорт может приехать несколькими
|
||
запросами (**Batch Requests**) — поэтому идемпотентность нужна на уровне
|
||
точки, а не пакета.
|
||
|
||
Мета-информации в **теле нет вообще** — только `{"data": {…секции…}}`. Из
|
||
заголовков в коде опираемся лишь на `automation-id` (стабильный UUID
|
||
автоматизации) и `session-id`: `automation-aggregation` и `automation-period`
|
||
называют настройку, а не фактический режим, и значение `Default` соответствует
|
||
трём разным поведениям (находка 31). Гранулярность и охват определяем по самим
|
||
данным. Полный набор заголовков сохраняется в `delivery.headers` — документация
|
||
заведомо неполна, и именно из незадокументированного вышли самые полезные
|
||
находки.
|
||
|
||
```
|
||
{"data": {"metrics": [...], "workouts": [...], "stateOfMind": [...],
|
||
"medications": [...], "symptoms": [...], "cycleTracking": [...],
|
||
"ecg": [...], "heartRateNotifications": [...]}}
|
||
```
|
||
|
||
Метрика — `{"name": "heart_rate", "units": "count/min", "data": [...]}`.
|
||
**Форма точки зависит от метрики**: обычная — `{qty, date}`, пульс —
|
||
`{Min, Avg, Max, date}`, давление — `{systolic, diastolic}`, сон — набор
|
||
интервалов и фаз, глюкоза — плюс `mealTime`. Единой формы значения нет;
|
||
общее — только момент времени.
|
||
|
||
Вопреки документации, в точке **есть поле `source`** — какие устройства
|
||
вложились в значение (составное, через `|`). Что ещё документация описывает
|
||
неверно и как поток выглядит на самом деле — [research/apple-health.md](research/apple-health.md).
|
||
|
||
Даты приходят строкой с офсетом: `2026-07-31 12:00:00 +0300` — не RFC 3339.
|
||
|
||
Тренировка (v2) несёт стабильный `id` из HealthKit, `start`/`end`/`duration`,
|
||
опционально `route` (точки GPS) и `heartRateData`.
|
||
|
||
**Наша настройка:** несуммированные данные (переключатель «Суммировать
|
||
данные» выключен, группировка при этом недоступна). Причина — суммированные
|
||
значения досчитываются задним числом: минутное ведро уезжает неполным и в
|
||
следующей доставке приезжает полным
|
||
([research/apple-health.md](research/apple-health.md), находка 10). На несуммированных
|
||
данных расхождений не наблюдалось (находка 3), поэтому идентичность по
|
||
содержимому работает без оговорок. Заодно сохраняются детали, которые
|
||
группировка съедает: эпизоды сна и межударные интервалы (находки 6, 19).
|
||
|
||
Чего это **не** даёт: настоящих сэмплов. Накопительные метрики (энергия,
|
||
шаги, дистанция) в любом режиме приходят посекундной сеткой — нарезкой
|
||
реальных сэмплов длиной 1–12 секунд, с сохранением итога и потерей границ
|
||
интервала. Порядка 135 тысяч точек в сутки (находки 20, 23).
|
||
|
||
Поля `start`/`end` есть только у дискретных метрик (пульс, сатурация, сон,
|
||
HRV); у накопительных — только `date`. Поэтому точка относится к часу **по
|
||
`date`**, а вопрос о сэмплах, пересекающих границу часа, касается сотой доли
|
||
данных (находка 21).
|
||
|
||
## Модель синхронизации
|
||
|
||
У модели два независимых измерения: **глубина окна** (как далеко назад
|
||
переспрашиваем) и **подробность** (в какой слой попадут данные). Проходы
|
||
задаются их сочетанием.
|
||
|
||
По глубине — три прохода, догоняющие друг друга:
|
||
|
||
| проход | расписание | период | зачем |
|
||
|---|---|---|---|
|
||
| быстрый | каждые 5 минут | Since Last Sync | свежесть |
|
||
| средний | 3–4 раза в день | **Today** | чинит пропуски за сутки |
|
||
| глубокий | раз в сутки | **Previous 7 Days** | чинит всё остальное |
|
||
|
||
По подробности — что в какой слой:
|
||
|
||
| подробность | набор метрик | слой |
|
||
|---|---|---|
|
||
| без группировки | только несуммируемые: сон, пульс, HRV | `raw` |
|
||
| минутная | все метрики здоровья | `minute` |
|
||
| часовая | все метрики здоровья | `hour` |
|
||
| ручной экспорт | всё, раз в 2–3 месяца | `sample` |
|
||
|
||
Набор нижнего слоя определяется не важностью метрики, а тем, **можно ли её
|
||
складывать**. Для пульса и HRV посекундная подробность несёт форму сигнала,
|
||
которой в минутном разрезе нет. Для шагов и энергии нижний слой HAE — это
|
||
интерполяция, которая не сходится в сверке (находка 34); держать её значило бы
|
||
хранить втрое больший объём ради худших чисел.
|
||
|
||
Секции без группировки в интерфейсе HAE (`stateOfMind`, `symptoms`, `ecg`,
|
||
`heartRateNotifications`, `cycleTracking`, `medications`) идут как есть — у них
|
||
подробности нет, есть только глубина окна.
|
||
|
||
Средний и глубокий проходы используют **фиксированные окна, а не метку
|
||
синхронизации** — это принципиально. Инкрементальный режим проверен и
|
||
**теряет данные**: в окне, которое он якобы покрыл, широкая выгрузка нашла
|
||
13 961 точку, включая фазы сна за три часа и весь глубокий сон той ночи
|
||
(находка 29). Фиксированное окно идемпотентно по построению и не зависит ни от
|
||
какой метки.
|
||
|
||
Расписание — пожелание, а не гарантия: iOS не даёт приложению запускаться в
|
||
заданное время, а к данным Health доступа нет вовсе, пока телефон заблокирован
|
||
(находка 28). Поэтому проходы привязываются к моментам, когда телефон заведомо
|
||
разблокирован (триггер из Shortcuts по времени суток), а поток считается
|
||
пачечным: тишина ночью, всплеск утром.
|
||
|
||
Гарантия починки:
|
||
|
||
```
|
||
дыра моложе суток → закроется в течение часа
|
||
дыра моложе недели → закроется в течение суток
|
||
дыра старше недели → не закроется; лечится `healthlog import`
|
||
```
|
||
|
||
Широкие проходы почти бесплатны именно из-за часовых объектов: глубокий проход
|
||
переприсылает неделю, но это 26 метрик × 168 часов ≈ 4400 сравнений хеша,
|
||
почти все из которых сойдутся, и записи не будет.
|
||
|
||
**Прежнее правило «настройки данных у всех проходов одинаковы» снято.** Оно
|
||
существовало потому, что метрика, приехавшая с разной группировкой,
|
||
перетирала сама себя по одному ключу (находка 14). С тех пор слой вошёл в
|
||
ключ, и минутная точка с часовой больше не сталкиваются — они в разных рядах.
|
||
Именно это и позволяет наполнять слои разными автоматизациями намеренно.
|
||
|
||
Условие, при котором это безопасно: слой выводится **из выравнивания меток, а
|
||
не из настройки автоматизации**. Перенастроил автоматизацию — данные просто
|
||
пойдут в другой слой, без порчи уже накопленного.
|
||
|
||
### Досчёт задним числом
|
||
|
||
Метрики правятся после факта, и глубина правки резко разная по классам:
|
||
|
||
- **Количественные** (пульс, шаги, энергия) человек руками не правит; они
|
||
опаздывают на часы. Наблюдались правки хвоста возрастом до 22 минут.
|
||
Недельного глубокого прохода достаточно.
|
||
- **Ручные записи** (`symptoms`, `medications`, `stateOfMind`,
|
||
`cycleTracking`) заводятся задним числом на недели и месяцы — симптом или
|
||
приём лекарства можно отметить за прошлую дату.
|
||
|
||
Поэтому окно досчёта **не единое**. Растягивать глубокий проход на месяц по
|
||
всем метрикам в минутном разрезе нельзя: тела запросов и так доходили до
|
||
42 МБ (находка 23), а месяц минутных данных — это десятки мегабайт на каждую
|
||
доставку. Вместо этого редкий широкий проход **только по ручным секциям**: их
|
||
единицы записей, и месячное окно там почти ничего не стоит.
|
||
|
||
Правило слияния одинаково для всех проходов. Порядок прихода при этом значение
|
||
**имеет**: полнота решает первой, а при равной полноте побеждает пришедшая
|
||
позже по журналу. «Последние данные всегда актуализируют картину» остаётся
|
||
неверным ровно в одном разряде — более полная точка бедную не пропускает
|
||
(см. «Разрешение столкновений»).
|
||
|
||
Автоматизации различимы по заголовку `automation-id`; имена стоит задать,
|
||
иначе `automation-name` приходит пустым (находка 12).
|
||
|
||
## Компоненты
|
||
|
||
Пакет — это реализация; **что система делает, нормативно сказано в
|
||
capability**, и здесь стоит ссылка, а не пересказ требований.
|
||
|
||
| Пакет | Ответственность | Capability |
|
||
| ---------- | ------------------------------------------------------ | ---------- |
|
||
| `config` | загрузка и валидация TOML-конфига | — |
|
||
| `logging` | сборка slog-логгера | — |
|
||
| `ident` | генерация и разбор ULID | — |
|
||
| `archive` | сырой архив: запись тела, чтение для reindex, ретеншен | [`storage`](../openspec/specs/storage/spec.md) |
|
||
| `hae` | разбор формата HAE, канонизация, хеш содержимого | [`parsing`](../openspec/specs/parsing/spec.md) |
|
||
| `ingest` | use-case приёма, общий для HTTP и CLI `import` | [`ingest`](../openspec/specs/ingest/spec.md) |
|
||
| `fold` | свёртка одной доставки в часовые объекты | [`storage`](../openspec/specs/storage/spec.md), [`uncovered-sections`](../openspec/specs/uncovered-sections/spec.md) |
|
||
| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт | [`reindex`](../openspec/specs/reindex/spec.md) |
|
||
| `catalog` | каталог разрезов и измерение рода агрегации | [`catalog`](../openspec/specs/catalog/spec.md) |
|
||
| `store` | SQLite: доставки, часовые объекты, тренировки, записи | [`storage`](../openspec/specs/storage/spec.md) |
|
||
| `points` | ряд точек метрики за период: выбор слоя, применимость рода | [`points`](../openspec/specs/points/spec.md) |
|
||
| `httpapi` | приём, read API и **форма провода** ответов чтения | [`ingest`](../openspec/specs/ingest/spec.md), [`catalog`](../openspec/specs/catalog/spec.md), [`read-api`](../openspec/specs/read-api/spec.md), [`points`](../openspec/specs/points/spec.md) |
|
||
|
||
## Приём
|
||
|
||
```
|
||
запрос → токен → лимит тела, gzip → проверка формы JSON
|
||
→ запись тела в архив → строка в delivery → 200
|
||
↓
|
||
фоновый воркер: разбор → запись в витрину
|
||
```
|
||
|
||
**Ответ отдаётся до свёртки, и это контракт, а не деталь реализации.** `200`
|
||
означает «тело сохранено и учтено»; разобрано ли оно, говорит
|
||
`delivery.parse_status`, и говорит позже. Причина измерена: свёртка 16 тысяч
|
||
точек занимает 11 секунд, а `WriteTimeout` в Go ставится в `readRequest` — то
|
||
есть до вызова обработчика — и потому является общим бюджетом на чтение тела,
|
||
запись архива, учёт и свёртку. Исчерпав его, сервер считает, что отдал `200`,
|
||
клиент получает обрыв, а `accessLog` пишет `status_code=200`: единственный канал
|
||
наблюдаемости врёт. Бьёт это по широким проходам — ровно по тем, ради которых
|
||
заведён инвариант «дыры закрываются сами».
|
||
|
||
Отсюда же второй бюджет: длинный дедлайн ответа выставляет **сам обработчик
|
||
приёма**, а не конфиг сервера. `write_timeout` глобален, и поднять его значило бы
|
||
снять защиту от застрявшей записи со всех маршрутов ради одного.
|
||
|
||
Код ответа определяется **доставкой**, не разбором:
|
||
|
||
- **400** — тело не разбирается как JSON ожидаемой верхнеуровневой формы.
|
||
Это проблема транспорта (обрыв, обрезанное тело), и отправителю о ней
|
||
надо сказать.
|
||
- **200** — тело сохранено в архив. Дальше даже полный провал разбора
|
||
(незнакомая метрика, новая форма точки) не меняет ответ: данные уже в
|
||
безопасности, исход разбора виден в логе, в `delivery.parse_status` и в
|
||
`/stats`, а доразобрать их можно командой `reindex`.
|
||
|
||
#### Очередь свёртки — таблица, а не структура в памяти
|
||
|
||
Доставка ждёт свёртки в собственном статусе `pending`; канал между приёмом и
|
||
воркером несёт один бит «есть работа». Это **transactional outbox**, он же «база
|
||
как очередь заданий»: состояние задания пишется той же базой, что и факт
|
||
события, а фоновый процесс выбирает необработанные строки.
|
||
|
||
Три следствия, ради которых так и сделано:
|
||
|
||
- **переполнять нечего** — доставка `pending` всегда, пока не свёрнута, поэтому
|
||
«очередь переполнена» невыразимо;
|
||
- **падение процесса очереди не теряет** — транзакция свёртки откатывается,
|
||
статус остаётся `pending`;
|
||
- **подбор `pending` при старте не является отдельным кодом** — это обычный
|
||
проход воркера, а не особый режим.
|
||
|
||
Отвергнут **канал идентификаторов в памяти**: он вводит второе, недолговечное
|
||
представление того же факта, и эти два расходятся при каждом падении; политика
|
||
переполнения всё равно требует подбора из базы, то есть того же кода — только в
|
||
двух экземплярах. Отвергнут и **опрос по таймеру вместо сигнала**: полпериода
|
||
задержки на каждую доставку без пользы. Тик при этом взят **в дополнение** к
|
||
сигналу: доставка, оставшаяся в очереди по обстоятельствам, иначе ждала бы
|
||
следующей доставки, а ночью телефон молчит часами.
|
||
|
||
Воркер один, и порядок у него тот же, что у пересборки — `(received_at, id)`:
|
||
слой доставки без плотных метрик наследуется от предшествующей доставки той же
|
||
автоматизации, то есть является функцией префикса журнала. Обещается достижимое:
|
||
в этом порядке сворачивается всё, что **видно воркеру** на момент выборки;
|
||
абсолютного порядка при конкурентных приёмах нет и быть не может без сериализации
|
||
самого приёма.
|
||
|
||
Классификацию исхода свёртки воркер и пересборка делят (`internal/replay`):
|
||
второй классификатор разошёлся бы с первым молча, а по одному из его счётчиков
|
||
(`partial`) принимается решение о судьбе тела в архиве.
|
||
|
||
**Исход разбора отражает доставку, а не обстоятельства.** Отмена и занятость
|
||
базы статус не меняют — доставка остаётся `pending` и будет свёрнута снова;
|
||
непонятое содержимое, невыводимый слой, нечитаемое тело, исчерпанный дедлайн и
|
||
паника свёртки дают `failed`. Различение появилось не из аккуратности: `failed`
|
||
из очереди выбывает навсегда и возвращается только пересборкой, а конкуренция за
|
||
базу между приёмом и свёрткой стала штатной — без него занятость стирала бы
|
||
доставку с полки молча. По той же причине учёт доставки идёт через транзакцию с
|
||
повторами: одиночная вставка пересиживала бы только `busy_timeout`, после чего
|
||
приём ответил бы `500` по доставке, тело которой уже на диске.
|
||
|
||
**Паника свёртки перехватывается там же, где пишется исход разбора.** Пока
|
||
свёртка шла внутри обработчика, панику ловил транспорт и стоила она одного
|
||
ответа; из фоновой горутины она валит процесс, а перезапуск берёт ту же доставку
|
||
первой — дефект одной доставки становится циклом перезапуска, при котором приём
|
||
не работает вовсе.
|
||
|
||
**Предел порядка назван вслух.** Метка приёма фиксируется раньше, чем строка
|
||
учёта становится видимой, поэтому две одновременные доставки могут закоммитить
|
||
строки в обратном порядке. Доставка без плотных метрик, свёрнутая раньше своей
|
||
предшественницы, слоя не выведет и уйдёт в `failed`: её точки доедут только
|
||
пересборкой. Окно узкое, и изменение его сужает, а не открывает, — но закрытие
|
||
предела требует удерживать порядок на самом приёме, и это отдельный вопрос
|
||
(задача `journal-order-on-ingest`).
|
||
|
||
Остановка формулируется **инвариантом**: приём прекращается раньше воркера, и
|
||
после остановки не существует доставки, которая числится разобранной, а записана
|
||
наполовину. Обещать «текущая доставка досворачивается» нельзя — бюджет остановки
|
||
(30 с) меньше бюджета свёртки (2 мин).
|
||
|
||
#### Частичный разбор
|
||
|
||
Разбор покрывает `metrics`, `workouts` и `stateOfMind`; `symptoms`, `ecg`,
|
||
`cycleTracking`, `medications` и `heartRateNotifications` проходят мимо. Живой
|
||
поток последних не приносил ни разу (118 доставок: 65 с метриками, 27 с
|
||
тренировками, 26 с состоянием разума), так что сегодня непокрытая секция —
|
||
редкость, а не половина потока, как было до покрытия сущностей.
|
||
|
||
Такая доставка получает статус `partial`, а имена непокрытых секций — колонку
|
||
`delivery.uncovered_sections`. Статус отвечает на вопрос «разобрано ли всё»,
|
||
список — «что именно осталось»; спрашивать полагается статус. Без этого
|
||
различения `parsed` означал бы «разобрано» и для доставки, из которой не
|
||
прочитано ни байта, а ретеншен, поверив ему, срезал бы тело — необратимо для
|
||
`stateOfMind`, которого в экспорте Apple нет.
|
||
|
||
Перечисление идёт **в том же проходе**, что и разбор метрик: значение
|
||
непокрытой секции проглатывается декодированием в выбрасываемый `RawMessage`,
|
||
поэтому копия одна, живёт до следующего члена и удерживается ноль (измерено:
|
||
тело 40 МиБ, из которых почти всё — непокрытая секция, удерживает 0 МиБ).
|
||
Пропуск ручным счётом глубины по токенам этого не даёт: делимитеры идут мимо
|
||
сканера, ограничитель вложенности `encoding/json` не работает, и тело из
|
||
вложенных скобок съедает память вместо отказа.
|
||
|
||
`partial` — не отклонение, а установившееся состояние, поэтому уровень лога от
|
||
него не растёт. Постоянный `WARN` каждые пять минут обесценил бы уровень.
|
||
|
||
**Первая встреча имени — другое дело**
|
||
([`uncovered-sections`](../openspec/specs/uncovered-sections/spec.md)). Момент, когда поток принёс секцию,
|
||
которой раньше не было, фиксировался колонкой, но не наблюдался ничем: увидеть
|
||
его мог только тот, кто догадается заглянуть в базу. Теперь свёртка спрашивает
|
||
журнал, встречалось ли имя в доставках **строго раньше** этой (пара
|
||
`(received_at, id)`, запросом вне транзакции записи), и первая встреча даёт
|
||
`WARN` с именами отдельным атрибутом `uncovered_new`. Повторные молчат. Признак
|
||
выводится, а не хранится: реестр был бы второй копией факта, обязанной сходиться
|
||
с колонкой при каждой пересборке. Отсюда же идемпотентность — проигрывание
|
||
полного журнала повторяет ровно те же события.
|
||
|
||
Событие переживает **отказ** свёртки: список непокрытых секций переживает его
|
||
(доставка с невыводимым слоем всё равно пишет имена), и смолчать значило бы
|
||
потерять событие навсегда — следующая доставка сочла бы имя виденным. А
|
||
отложенный по обстоятельствам исход событий не даёт: учётной записи он не
|
||
меняет, доставка вернётся следующим проходом.
|
||
|
||
Перечень накопленного отдаёт `healthlog uncovered` — имя, число доставок,
|
||
первая и последняя встреча, чтением только на чтение и с экранированием имён
|
||
(ключ приходит из чужого тела). Границы у перечня три, и они названы, а не
|
||
замолчаны: имя, вытесненное границей списка в 32 имени, в колонку не попадает
|
||
вовсе; пересборка обнуляет колонку и заполняет её заново только по сохранившимся
|
||
телам; а имя, секцию которого разбор научился покрывать, уходит из колонки при
|
||
пересвёртке — то есть перечень отвечает о текущем состоянии покрытия, а не об
|
||
истории.
|
||
|
||
Цена сверки измерена на синтетическом журнале годового объёма; числа и метод
|
||
живут в одном месте — `design.md` изменения `aktivnaya-proverka-novyh-sekcij`,
|
||
решение 3, — и здесь не дублируются. Правило из замера: ранний выход есть только
|
||
у секции, приезжающей давно (строки просматриваются от старых к новым); у только
|
||
что появившейся секции проход идёт почти по всему журналу на каждой доставке,
|
||
пока её не покроет отдельная задача. Имён больше одного спрашиваются одним
|
||
запросом — тридцать два запроса подряд стоили секунду с лишним на доставку.
|
||
|
||
**Правило для будущих задач: покрыли секцию — пересверните.** Список это снимок
|
||
покрытия на момент свёртки; доставки, свёрнутые до того, как секция стала
|
||
покрытой, останутся `partial` со старым списком, и ретеншен будет вечно щадить
|
||
ненужные тела. Задача, которая начинает разбирать секцию, тем же изменением
|
||
переводит `partial`-строки с этим ключом в `pending`. Так сделала миграция
|
||
`00007`, покрывшая `workouts` и `stateOfMind`.
|
||
|
||
**Следствие для ретеншена, названное вслух.** Пока `stateOfMind` был непокрыт,
|
||
его тела защищал сам статус `partial`. Теперь такая доставка получает `parsed` и
|
||
неотличима от доставки из метрик — а метрики восстановимы из экспорта Apple,
|
||
состояние разума нет (находка 46). Ретеншена в проекте нет, поэтому сегодня не
|
||
ломается ничего; но предусловие, которое задача ретеншена считала снятым, снова
|
||
открыто, и признак невосстановимости придётся завести отдельно от «непокрытости».
|
||
|
||
- **413** — тело больше допустимого. Граница стоит на **распакованном**
|
||
потоке, а не только на сжатом: `MaxBytesReader` поверх `r.Body` ограничивает
|
||
то, что приехало по сети, а в память попадает то, что из этого развернулось.
|
||
Измерено: 400 КиБ сжатого тела давали 400 МиБ и гигабайт выделений при
|
||
лимите в мегабайт. Потолок степени сжатия gzip около 1030:1, так что при
|
||
штатных 64 МиБ речь о десятках гигабайт на запрос, и параллельные
|
||
складываются. Цена отказа здесь наивысшая в проекте: приём — единственное
|
||
место, где поток вообще существует, и доставка, не попавшая в архив, не
|
||
попадает в журнал. Та же граница действует при чтении тела из архива —
|
||
иначе тело между двумя границами принималось бы с `200`, а потом вечно
|
||
валилось бы при каждой пересборке.
|
||
|
||
Причина такого разделения: неизвестно, шлёт ли HAE отклонённый пакет
|
||
повторно при периоде «Since Last Sync». Если не шлёт, строгий приём означал
|
||
бы дыру в истории. Многоуровневая синхронизация страхует тот же риск с другой
|
||
стороны — но полагаться только на неё нельзя: она чинит дыры за неделю, а не
|
||
за год.
|
||
|
||
## Хранилище
|
||
|
||
### Сырой архив и восстановление состояния
|
||
|
||
<!-- канон: поведение → openspec/specs/reindex -->
|
||
|
||
`raw/ГГГГ/ММ/ДД/<ulid>.json.gz` — тело запроса как пришло, не редактируется.
|
||
|
||
Два источника вместе образуют **полный журнал событий**, а хранилище —
|
||
свёртку по нему:
|
||
|
||
```
|
||
состояние = import(последний проверенный экспорт) ← снапшот всей истории
|
||
+ replay(доставки после его даты) ← хвост событий
|
||
```
|
||
|
||
Экспорт Apple — не просто «источник истины для нижнего слоя», а снапшот: он
|
||
содержит всю историю целиком (3.6 млн записей с 2019 года). Доставки HAE после
|
||
его даты — события поверх снапшота. Значит любое повреждение хранилища,
|
||
включая ошибку в нашем разборе любой давности, лечится пересборкой, а не
|
||
восстановлением из бекапа.
|
||
|
||
Отсюда три следствия, каждое из которых меняет реализацию.
|
||
|
||
**Срок жизни архива определяется циклом экспорта, а не календарём.** Прежние
|
||
14 дней были произвольным числом. Правильное правило: доставки хранятся **до
|
||
следующего проверенного экспорта**, иначе в журнале появится дыра между концом
|
||
ретеншена и датой снапшота. Цена измерена: поток даёт ~23 МБ архива в сутки,
|
||
то есть ~2 ГБ за квартал между экспортами. Это дёшево за возможность
|
||
пересобрать что угодно.
|
||
|
||
**Свёртка обязана быть детерминированной.** Проигрывание должно давать то же
|
||
состояние, что и приём в реальном времени. Разряд полноты коммутативен и
|
||
порядка не требует, а разряд равной полноты — **нет**: побеждает пришедшая, то
|
||
есть исход есть функция порядка свёртки. Отсюда два следствия. Воспроизведение
|
||
идёт строго по `(received_at, id)`, а не по порядку файлов в каталоге. И живая
|
||
свёртка обязана идти тем же порядком: проход воркера прекращается на первой
|
||
отложенной доставке, а свёртка, всё-таки пошедшая вне порядка (конкурентный
|
||
приём делает строку учёта видимой позже метки), пишет `WARN` — закрыть это окно
|
||
можно только на приёме.
|
||
|
||
**`reindex` и `import` — одна операция, а не две.** Восстановление это импорт
|
||
снапшота плюс проигрывание хвоста; отдельной «пересборки из архива» не
|
||
существует, она просто вырожденный случай с пустым снапшотом. Проигрывание
|
||
живёт в `internal/replay`; `healthlog import` добавит стадию снапшота **перед**
|
||
ним, а не заведёт вторую похожую операцию.
|
||
|
||
**Журналом считается архив, а не таблица доставок.** Перечислять строки
|
||
`delivery` значило бы пересобирать витрину из витрины. Тело может лежать в
|
||
архиве без учётной записи: приём кладёт его на диск раньше строки в базе
|
||
(обратный порядок дал бы учтённую доставку без данных), и отказ на вставке
|
||
оставляет тело без учёта — такое тело пересборка заводит заново, восстанавливая
|
||
метку приёма из ULID, а размер и хеш пересчитывая по распакованному телу.
|
||
Обратный случай — строка без тела — станет штатным вместе с ретеншеном и потому
|
||
считается, а не роняет прогон.
|
||
|
||
**Заголовков доставки в архиве нет**, и это named предел модели: они живут
|
||
только в `delivery`, поэтому пересборка читает рабочую базу, а полная потеря
|
||
базы деградирует вывод слоя навсегда. Закрывается это тем, что заголовки надо
|
||
класть в архив рядом с телом (так делает WARC) — отдельная задача беклога.
|
||
|
||
#### Пересборка идёт в отдельный файл, а подмену делает человек
|
||
|
||
Пересборка обязана начинаться с **пустой** витрины: точки из объекта не
|
||
удаляются никогда, поэтому проигрывание поверх накопленного оставило бы в ней
|
||
результат прежнего, неверного разбора — то есть не сделало бы того, ради чего
|
||
она существует.
|
||
|
||
Начать с пустой можно двумя способами, и выбран второй.
|
||
|
||
- **Очистить рабочую витрину и проиграть в неё же** — отвергнуто. Единственная
|
||
необратимая операция всей задачи (`DELETE FROM bucket`) выполнялась бы **до**
|
||
того, как станет известно, удалась ли пересборка; отказ на середине оставлял
|
||
бы витрину пустой наполовину в состоянии, неотличимом от нормального.
|
||
- **Собрать рядом и подменить** — взято. Это blue-green rebuild проекции,
|
||
стандартный приём event sourcing («вместо усечения существующей модели строим
|
||
новую в параллельном хранилище и переключаем чтение»); той же формы `_reindex`
|
||
с переключением алиаса в Elasticsearch и собственный `VACUUM INTO` SQLite.
|
||
Отказ становится бесплатным: рабочая база не тронута, промежуточный файл
|
||
удаляется.
|
||
- **Теневая таблица в той же базе** (`bucket_new` → переименование в
|
||
транзакции) — отвергнуто дважды. Имя `bucket` зашито литералом во весь слой
|
||
записи, то есть вариант требует параметризовать таблицей самый опасный код
|
||
проекта ради операции раз в полгода; и он не решает того, ради чего
|
||
затевался, — живой приём во время пересборки пишет в **старую** таблицу, и
|
||
при подмене его точки пропадают.
|
||
|
||
**Подмену рабочей базы делает человек, и это не лень.** Файл базы держит
|
||
открытым процесс сервиса, а переименование не касается уже открытого
|
||
дескриптора: процесс продолжит писать в отвязанный inode, читатели увидят новый
|
||
файл, данные разойдутся молча. Документация SQLite называет переименование
|
||
используемого файла прямой причиной порчи базы. Безопасная подмена требует
|
||
остановленного сервиса, а остановить его команда не может — сервисом управляет
|
||
окружение снаружи, и CLI, делающий вид, что управляет, обещал бы безопасность,
|
||
которой не обеспечивает. Поэтому команда печатает процедуру, а выполняет её
|
||
человек:
|
||
|
||
```
|
||
task down
|
||
healthlog reindex --config ./config.toml
|
||
mv ./data/healthlog.db.rebuild ./data/healthlog.db
|
||
rm -f ./data/healthlog.db-wal ./data/healthlog.db-shm
|
||
task up
|
||
```
|
||
|
||
Пересборка при этом **читает рабочую базу без наката миграций**: обычное
|
||
открытие мигрирует безусловно, а миграции меняют и данные (та, что ввела
|
||
частичный разбор, переписала `parse_status` у всех строк). Расхождение версии
|
||
схемы — отказ с указанием обеих, а не миграция под работающим сервисом.
|
||
|
||
**Оракул сходимости встроен в команду**: печатаются отпечаток рабочей витрины и
|
||
отпечаток пересобранной, снятые так, что первый берётся **до** проигрывания —
|
||
иначе под живым приёмом он движется, и ответ «разошлись» не значил бы ничего.
|
||
Пустой журнал при этом успехом не считается: отпечаток пустой витрины совпадает
|
||
с отпечатком пустой витрины, то есть выглядит идеальной сходимостью, а человек,
|
||
выполнивший напечатанную процедуру, заменил бы накопленное пустым.
|
||
|
||
Что пересборка **не** переносит: признак `sealed` (правила его выставления ещё
|
||
нет, переносить нечего) и производные от разбора поля учёта — `parse_status`,
|
||
`points`, `derived_layer`, `uncovered_sections`, `skipped_entities`. Перечень
|
||
пополняется **тем же изменением**, которое заводит новое поле: он единственное
|
||
место, где сказано, чему нельзя пережить пересборку.
|
||
|
||
Это не косметика. Доставка, чей повторный разбор отказал, отдала бы в
|
||
наследование слой прежнего разбора, и витрина снова стала бы функцией
|
||
предыдущего прогона, а не журнала. У числа пропущенных сущностей цена та же и
|
||
хуже: пустота у него означает «не измерялось», и перенесённое число выдавало бы
|
||
измерение прежнего разбора за измерение текущего — а по нему принимается
|
||
необратимое решение об удалении тела.
|
||
|
||
#### Что не восстанавливается, и это сказано вслух
|
||
|
||
Модель почти полна, но не полностью — умолчать об этом опаснее, чем признать.
|
||
|
||
**`stateOfMind` в экспорте отсутствует вовсе.** Проверено на свежем экспорте:
|
||
ни одного типа со словом `StateOfMind` (есть только `MindfulSession` — это
|
||
минуты осознанности, другое). Состояние разума живёт **только** в доставках
|
||
HAE. Значит для него доставки не хвост журнала, а единственный источник: либо
|
||
они не удаляются никогда, либо его история держится на самих сохранённых
|
||
записях и восстановлению не подлежит.
|
||
|
||
**Верхние слои за периоды с удалёнными доставками.** После проигрывания
|
||
снапшота у старого периода будет только слой `sample`; `minute` и `hour` за
|
||
него не воскреснут. Посчитать их вниз из `sample` технически можно — и нельзя
|
||
по инварианту: это была бы **наша** агрегация под видом присланной.
|
||
|
||
Поэтому правило: **восстановление не обязано быть побайтным, оно обязано быть
|
||
честным.** Каталог разрезов показывает, какие слои есть за какой период; после
|
||
пересборки старый период честно объявляет один слой вместо трёх, а не
|
||
притворяется, что ничего не изменилось.
|
||
|
||
### Версия витрины и обслуживание журнала
|
||
|
||
<!-- канон: поведение → openspec/specs/reindex -->
|
||
|
||
Два механизма живут рядом и держатся друг за друга: один говорит читателю «в
|
||
базу никто не коммитил», второй разбирает журнал, в который эти коммиты легли.
|
||
|
||
#### Версия витрины: пара «поколение + счётчик»
|
||
|
||
Читающие маршруты обязаны уметь отвечать «не изменилось» без сборки ответа —
|
||
самый частый запрос трёх потребителей это повтор неизменившегося. Признак
|
||
изменения берётся у SQLite: `PRAGMA data_version` меняется, когда в базу
|
||
закоммитило **другое** соединение.
|
||
|
||
Голым значением его брать нельзя, и обе причины измерены на стенде проекта:
|
||
|
||
- **счётчик несравним между соединениями.** На одном состоянии базы два
|
||
соединения пула отвечают разными числами, а любое свежее соединение отвечает
|
||
одним и тем же значением независимо от содержимого. Версия из пула давала бы
|
||
не только ложную инвалидацию (не страшно), но и **одинаковые метки на разных
|
||
состояниях** — то есть подтверждение неизменности на изменившихся данных.
|
||
- **счётчик не переживает переоткрытия.** После рестарта он начинается заново.
|
||
|
||
Поэтому версия читается с одного **закреплённого соединения-щупа**, а метка это
|
||
`поколение-счётчик`, где поколение — ULID, выданный соединению. Поколение
|
||
меняется при каждом пересоздании щупа и заодно при выкатке нового бинаря, то
|
||
есть смена **формы** ответа при неизменившихся данных тоже обнуляет метки.
|
||
Монотонной метка не является: сравнивать её можно только на равенство.
|
||
|
||
Три свойства щупа названы вслух, потому что каждое из них можно нарушить
|
||
незаметно:
|
||
|
||
- **щуп не пишет** — собственный коммит соединения его версию не двигает;
|
||
- **щуп не удерживает транзакцию**: только `QueryRowContext(...).Scan(...)`,
|
||
никаких `QueryContext` и `BeginTx`. Иначе единственное долгоживущее соединение
|
||
процесса становится вечным читателем — тем самым, из-за которого чекпойнт
|
||
перестаёт продвигаться;
|
||
- **щуп непригоден только после закрытия** (`sql.ErrConnDone`). Отмена запроса
|
||
клиентом соединение не убивает (измерено), и считать её смертью щупа значило
|
||
бы менять поколение на каждом оборванном запросе — механизм схлопывался бы под
|
||
той самой нагрузкой, ради которой заведён.
|
||
|
||
Закрытие хранилища освобождает щуп **раньше пула**: закреплённое соединение
|
||
переживает закрытие пула, а финальный чекпойнт SQLite делает при закрытии
|
||
последнего соединения. Забытый щуп оставил бы рядом с базой неразобранный
|
||
`-wal`, а пересборка, переносящая один файл `.db`, потеряла бы хвост записей
|
||
молча.
|
||
|
||
**Подписывается не ответ, а чтение целиком.** Версия снимается до и после
|
||
чтения, и метка выдаётся, только если обе пробы совпали. Порядок здесь не
|
||
стилистический: версия, снятая ПОСЛЕ чтения, пометила бы устаревший снимок
|
||
свежим номером и заперла бы клиента на нём навсегда; версия, снятая только ДО,
|
||
допускает два разных ответа под одной меткой. Правило живёт в одном месте
|
||
(`store.VersionedRead`), потому что Read API точек и MCP берут ту же машинерию,
|
||
а вторая реализация «по образцу» отличалась бы ровно на этот порядок.
|
||
|
||
Отказ пробы версией не является: читающий маршрут деградирует до полного
|
||
ответа, а не до отказа.
|
||
|
||
#### Обслуживание журнала WAL
|
||
|
||
`wal_autocheckpoint` включён по умолчанию и срабатывает **по концу записи**.
|
||
Отсюда дыра: всплеск, раздувший журнал, оставляет его неразобранным до следующей
|
||
доставки — а поток пачечный, ночью телефон молчит часами. Поэтому рядом с
|
||
воркером свёртки живёт горутина, раз в минуту делающая
|
||
`PRAGMA wal_checkpoint(PASSIVE)`.
|
||
|
||
Режим `PASSIVE`, и это тоже измерение: `TRUNCATE` двигает `data_version`, то
|
||
есть каждый тик обнулял бы условный запрос у всех потребителей, а вдобавок ждёт
|
||
читателей. `PASSIVE` не двигает версию даже перенося 12502 страницы.
|
||
|
||
**Признак беды — не флаг занятости.** Пассивный чекпойнт не идёт дальше снимка
|
||
самого старого активного читателя и ошибки при этом не возвращает: измерено
|
||
`busy=0` при 6256 страницах в журнале и пяти перенесённых. Признаком служит пара
|
||
чисел — страниц больше порога **и** перенесено меньше, чем лежало.
|
||
|
||
**Флаг занятости при этом означает не «не продвинулись», а «не измерено».** Не
|
||
взяв блокировку чекпойнта, SQLite отдаёт `busy=1` и `-1` вместо обоих чисел —
|
||
измерено, 1492 таких тика из 5502 при писателе и чекпойнте в цикле. Сравнивать
|
||
`-1` на шкале страниц нельзя буквально: `-1 >= -1` истинно, то есть
|
||
незамеренный тик читался бы как «журнал разобран целиком» — владельцу уходила бы
|
||
строка о выздоровлении посреди болезни, с числом, которого не бывает, а
|
||
подавитель повторов сбрасывался бы и давал пару строк в минуту вместо молчания.
|
||
Незамеренный тик поэтому не меняет ни объявленного состояния, ни накопленного о
|
||
нём. Размер страницы берётся у самой базы: он свойство файла, и чужое умолчание
|
||
сместило бы порог в разы.
|
||
|
||
Порог и `journal_size_limit` — одно число (64 МиБ), выраженное в двух видах:
|
||
предел возвращает файл, порог сообщает, что вернуть его не выходит. Двумя
|
||
константами они разъехались бы молча.
|
||
|
||
**Предела роста журнала это не даёт, и умалчивать об этом нельзя.** Измерено:
|
||
под удерживаемым читателем файл вырос до 51 МБ при лимите 8 МиБ — лимит
|
||
действует только после полного чекпойнта, усечение делает первая запись за ним.
|
||
Пока читатель держит снимок, журнал растёт, и единственный исход — `WARN`
|
||
владельцу. Аварийный клапан (блокирующий `TRUNCATE` по порогу размера, как у
|
||
Litestream) не взят по названной причине: он двигает версию витрины.
|
||
|
||
Строка о непродвижении пишется при входе в состояние и повторяется, только
|
||
когда журнал вырос вдвое; возврат к норме — отдельная строка. Признак заведён
|
||
ради состояния, которое само не проходит (в Go самый частый вечный читатель —
|
||
незакрытый `sql.Rows`), а строка в минуту дала бы 1440 одинаковых записей в
|
||
сутки.
|
||
|
||
#### Как это решают другие
|
||
|
||
- **Документация SQLite** (`wal.html`) называет наш случай дословно: при
|
||
перекрывающихся читателях, среди которых всегда есть активный, чекпойнты не
|
||
смогут завершиться, и файл журнала будет расти без границы. Оттуда же взято,
|
||
что `PASSIVE` «делает столько, сколько может» и может не дойти до конца, а
|
||
полнота проверяется равенством `checkpointed == log`.
|
||
- **Litestream** — интервал чекпойнта минута, режим `PASSIVE`, блокирующий
|
||
`TRUNCATE` только как клапан по порогу размера. Взят период и режим; не взят
|
||
его совет отключать `wal_autocheckpoint` (он владеет чекпойнтами целиком, у нас
|
||
автоматический — первая линия) и не взят клапан.
|
||
- **rqlite** всегда просит `TRUNCATE` и ждёт читателя до 250 мс — продиктовано
|
||
требованием нулевого журнала для снапшота Raft, которого у нас нет.
|
||
- **Гайды по SQLite в проде** (Django, `dj-lite`) из всего этого ставят одно —
|
||
`journal_size_limit` порядка 26–64 МБ. Взято 64 МиБ.
|
||
- **`PRAGMA data_version`**: рекомендация держать для наблюдения отдельное
|
||
соединение взята с форума SQLite. Отвергнуты: `FileControlDataVersion`
|
||
драйвера (снимает требование «щуп не пишет», но стоит доступа через
|
||
`(*sql.Conn).Raw` в самом чувствительном месте), счётчик изменений со
|
||
страницы 1 (`SQLITE_DBPAGE` — в режиме WAL инкрементируется не на каждой
|
||
транзакции), хеш файла базы (так делает Datasette в неизменяемом режиме —
|
||
наша база пишется непрерывно) и собственный счётчик версии в таблице (второе
|
||
производное состояние рядом с витриной и лишняя запись на каждый коммит).
|
||
|
||
### Устаревание нижнего слоя
|
||
|
||
<!-- канон: поведение → openspec/specs/storage -->
|
||
|
||
Родной экспорт Apple Health точнее HAE (находка 34) и делается раз в 2–3
|
||
месяца. Данные HAE в нижнем слое старше последнего экспорта избыточны: тот же
|
||
период лежит в слое `sample` подробнее и честнее.
|
||
|
||
Два ограничения, без которых правило опасно:
|
||
|
||
**Пометка вешается по загруженному экспорту, а не по сделанному.** Условие —
|
||
экспорт разобран, и проверено, что он **покрывает период**: непрерывность по
|
||
дням и сходимость сумм с часовым слоем. Иначе срок жизни данных повисает на
|
||
ручной операции, которую можно забыть или сделать наполовину, — а этот
|
||
механизм уже протекал: «Since Last Sync» молча потерял 13 961 точку, включая
|
||
ночь сна целиком (находка 29).
|
||
|
||
**Чистится только нижний слой.** Разница между слоями — три порядка: `hour`
|
||
это ~100 координат в сутки, `minute` ~3 700, `raw` ~100 000 (находка 41).
|
||
Удаление верхних слоёв не экономит ничего, но ломает ответы на исторические
|
||
запросы. Всё давление по объёму создаёт нижний слой, и ровно там экспорт —
|
||
настоящее надмножество.
|
||
|
||
Пометка «устарело» **не равна удалению**: сперва данные помечаются и остаются
|
||
доступными, удаление — отдельный шаг с собственным сроком. Пока восстановление
|
||
из экспорта не проверено на живых данных хотя бы раз, удаление не включается
|
||
вовсе.
|
||
|
||
Оговорка: для секций, которых в экспорте нет (ЭКГ выгружается отдельными CSV,
|
||
судьба `stateOfMind` и лекарств не проверена), экспорт источником истины не
|
||
является и правило к ним неприменимо — их держим всегда.
|
||
|
||
**Это осознанная смена источника истины.** Пока тело в архиве, истина — оно;
|
||
после удаления истиной остаются часовые объекты. Инвариант, который держит
|
||
конструкцию: **объект хранит точки дословно**. Если разбор начнёт что-то
|
||
отбрасывать или нормализовать внутри точки, срок хранения архива станет
|
||
сроком жизни данных.
|
||
|
||
### Часовые объекты метрик
|
||
|
||
<!-- канон: поведение → openspec/specs/storage -->
|
||
|
||
Точки метрик хранятся не по одной, а **пачками: один объект = одна метрика за
|
||
один час UTC**.
|
||
|
||
```
|
||
delivery(id, received_at, automation_name, automation_id, aggregation,
|
||
period, session_id, bytes, sha256, raw_path, parse_status, points,
|
||
headers, derived_layer, uncovered_sections, skipped_entities NULL)
|
||
|
||
bucket(metric, layer, hour_utc, units, payload BLOB, content_hash, points,
|
||
first_ts, last_ts, first_delivery_id, sealed, created_at, updated_at)
|
||
PK (metric, layer, hour_utc) WITHOUT ROWID
|
||
|
||
workout(id PK, name, start_utc, end_utc, tz_offset, duration_sec REAL NULL,
|
||
payload BLOB, content_hash, delivery_id, delivery_received_at,
|
||
created_at, updated_at)
|
||
INDEX (start_utc)
|
||
|
||
record(kind, id, ts_utc, tz_offset, payload BLOB, content_hash,
|
||
delivery_id, delivery_received_at, created_at, updated_at)
|
||
PK (kind, id) INDEX (kind, ts_utc)
|
||
```
|
||
|
||
Зачем пачками:
|
||
|
||
- **Строк на два порядка меньше** — 26 метрик × 24 часа = 624 объекта в сутки
|
||
вместо ~155 тысяч точек. За год 228 тысяч строк вместо 55 миллионов.
|
||
- **Дедупликация дешевеет во столько же раз.** Повторная доставка того же часа
|
||
— одно сравнение хеша вместо тысяч поисков по точкам. Это и делает широкие
|
||
проходы синхронизации почти бесплатными.
|
||
- **Хранение сжимается.** `payload` — gzip-BLOB: наблюдаемое сжатие такого
|
||
JSON — примерно 25 раз, то есть ~2 МБ в сутки вместо ~50 МБ. Цена: внутрь
|
||
объекта не заглянуть SQL-функциями, разбор только в приложении. Для
|
||
хранилища, которое отдаёт диапазоны точек, это не потеря.
|
||
|
||
### Слои гранулярности
|
||
|
||
<!-- канон: поведение → openspec/specs/storage -->
|
||
|
||
Одна и та же метрика может приходить с разной подробностью: несуммированной,
|
||
минутной, часовой. Мы **не сводим их к одной** и не агрегируем сами — храним
|
||
разрезами и говорим клиенту, какие разрезы есть.
|
||
|
||
```
|
||
sample настоящие сэмплы HealthKit с интервалами start/end — только из
|
||
ручного экспорта Apple Health, HAE такого не отдаёт (находка 34)
|
||
raw метки на произвольной секунде heart_rate 00:02:07
|
||
minute метки выровнены на минуту heart_rate 00:02:00
|
||
hour метки выровнены на час heart_rate 00:00:00
|
||
```
|
||
|
||
Почему не переагрегируем **при записи**: правильный способ свёртки зависит от
|
||
метрики (сумма для энергии, среднее для пульса), и ошибка здесь необратима —
|
||
исходные точки уже не вернуть. При записи слои остаются раздельными всегда.
|
||
|
||
Свести их **в ответе** можно, и Read API это делает, — но род свёртки не
|
||
проставляется вручную, а **измеряется**: одна метрика лежит в минутном и
|
||
часовом разрезе одновременно, и если часовое значение сходится с суммой
|
||
минутных, метрика накопительная; если со средним — мгновенная. Форма точки
|
||
рода не выдаёт (`Avg`/`Min`/`Max` есть только у `heart_rate`), единицы дают
|
||
процентов девяносто и ломаются на краях — `six_minute_walking_test_distance`
|
||
в метрах складывать нельзя, а `walking_running_distance` в километрах можно
|
||
(находка 40). Где данных на сверку не хватило, род остаётся неизвестным и
|
||
агрегация по метрике не предлагается вовсе. Правило целиком — ниже, «Измерение
|
||
рода агрегации».
|
||
|
||
Отдельно: **нижний слой HAE не суммируется никогда.** Он не сэмплы, а
|
||
посекундная развёртка (находка 34) и в сверке не сходится — сумма по нему
|
||
даёт завышение. Накопительные метрики агрегируются только из `minute`,
|
||
`hour` или `sample`.
|
||
|
||
**Слой — это режим выгрузки, которым пришли данные**, а не измеренное
|
||
разрешение каждой метрики. Различие принципиально: частота метрик разная —
|
||
пульс идёт секундами, VO₂ max случается раз в неделю, — и выводить слой из
|
||
частоты значило бы дробить редкие метрики между слоями без всякого смысла.
|
||
Режим же общий для доставки, и редкая метрика просто наследует его.
|
||
|
||
**Определяется по данным, а не по заголовку.** `automation-aggregation`
|
||
непригоден: значение `Default` соответствует трём разным режимам сразу
|
||
(находка 31). Правило:
|
||
|
||
1. **Плотная метрика** (не меньше десяти точек в доставке) классифицируется
|
||
**сама по себе** по выравниванию своих меток — по **самому мелкому**
|
||
встретившемуся, а не преобладающему: метка ровно на часе одновременно
|
||
является и минутной, и у плотных метрик они перемешаны (`active_energy` —
|
||
1320 минутных и 21 часовая). У десяти несуммированных точек шанс всем лечь
|
||
на ровную минуту исчезающе мал.
|
||
2. **Редкая метрика** (меньше десяти точек) наследует **преобладающий слой
|
||
доставки** — самый мелкий среди плотных. У неё выравнивание ничего не
|
||
доказывает, а Apple многие редкие показатели пишет прямо на границе часа.
|
||
3. Плотных метрик в доставке нет вовсе — слой наследуется от **предшествующей**
|
||
доставки той же автоматизации; если её не было, берём **надёжный** заголовок
|
||
(`Minutes` → `minute`, `Hours` → `hour`). Иначе точки не сохраняются вовсе:
|
||
молчаливый `raw` создал бы призрачный разрез, который поедет в каталог и в
|
||
правило Read API выбора слоя (см. «Read API»).
|
||
|
||
Слово «предшествующей» в третьем пункте несёт вес: слой обязан быть функцией от
|
||
**префикса журнала**. Наследование от последней доставки вообще делает свёртку
|
||
зависящей от истории, и пересборка даёт не то состояние, что живой приём —
|
||
поймано прогоном архива, 1737 объектов против 1742 (docs/review.md).
|
||
|
||
Классифицировать доставку целиком нельзя: при перенастройке автоматизации
|
||
приезжают **смешанные доставки**, где часть метрик уже минутная, а часть ещё
|
||
посекундная. Одна такая доставка, отнесённая к слою целиком, сложила минутные
|
||
точки с посекундными и удвоила сумму за час (находка 35).
|
||
|
||
Заголовок сохраняем и сверяем с выведенным; расхождение и смену режима у
|
||
автоматизации пишем `WARN` — так видна перенастройка, а не тихий дребезг.
|
||
|
||
Почему не по метрике отдельно (проверено на живых данных, находка 33): одна
|
||
доставка законно содержит метрики разной подробности — `apple_stand_hour`
|
||
почасовой по своей природе, `sleep_analysis` в минутном режиме превращается в
|
||
суточный агрегат на `00:00:00`, а `heart_rate` рядом с ними идёт с секундной
|
||
точностью. Классификация каждой по отдельности растащила бы одну выгрузку по
|
||
трём слоям.
|
||
|
||
**Исключение — `sleep_analysis`.** Под этим именем HAE шлёт две несовместимые
|
||
схемы: поэпизодную (`start`/`end`/`value`/`qty`) и суточную сводку
|
||
(`totalSleep`/`core`/`rem`/`deep`/`awake`, метка на местной полуночи). Общих
|
||
полей, кроме `date` и `source`, у них нет, источники тоже разные — эпизоды от
|
||
стороннего приложения, сводка от часов (находка 38). Правило выравнивания на
|
||
сводке даст `hour`, хотя это суточный итог, а не часовой разрез. Поэтому в
|
||
каталоге они разводятся на два имени — `sleep_analysis` и
|
||
`sleep_analysis_summary`, — и слой у сводки не выводится, а фиксирован как
|
||
`day`. Хранение остаётся дословным: разводятся имена, а не содержимое.
|
||
|
||
Пересборка применяет к уже разобранному **исправленный** разбор — это и есть
|
||
причина держать сырой архив. Точнее она именно этим, а не тем, что видит более
|
||
длинный ряд: слой обязан оставаться функцией **префикса** журнала, и наследование
|
||
«от последней доставки вообще» уже ловили дефектом (1737 объектов против 1742,
|
||
`docs/review.md`).
|
||
|
||
Следствие: **пересечение наборов метрик между автоматизациями перестаёт быть
|
||
проблемой**. Минутный и несуммированный `heart_rate` наполняют разные слои и
|
||
не смешиваются в одном ряду; если же две автоматизации шлют одну метрику с
|
||
одинаковой гранулярностью, это честный дубликат, и его схлопывает хеш.
|
||
|
||
Слои считаются **вниз, но не вверх**: из `raw` получается `hour`, обратно —
|
||
нет. Поэтому самый мелкий слой стоит держать, пока он не станет дорог; цена
|
||
измерена — около 730 МБ в год против 20 МБ у минутного. Страховка на случай,
|
||
если мелкий слой всё-таки выключат: **ручной экспорт из Apple Health**
|
||
восстанавливает нижний слой целиком через `healthlog import`.
|
||
|
||
**Идентичность точки — координаты, а не содержимое.**
|
||
|
||
```
|
||
ключ: метрика + слой + начало + конец конец = начало, если end нет
|
||
значения: qty / Min / Avg / Max / source / … ← перезаписываются
|
||
```
|
||
|
||
**Ключ — интервал, а не метка.** Метка на записи сна не уникальна: под одним
|
||
`date` лежит до трёх записей, и это не дефект, а способ Apple выразить
|
||
вложенность «в кровати» и фазы внутри неё. Измерено на всём корпусе (находка
|
||
47): 174 координаты против 170 по метке, ноль столкновений против 33 **внутри
|
||
одной доставки**, где тай-брейк по времени приёма неприменим в принципе.
|
||
`value` в ключе ничего не добавляет.
|
||
|
||
Форма ключа **одна для всех точек**. Интервалы несут 22 метрики, а не только
|
||
сон; `start`, когда он есть, всегда равен `date`; обе формы точки не
|
||
смешиваются внутри метрики одной доставки. Поэтому отдельного класса «эпизодных
|
||
метрик» нет — нечего выводить и нечего поддерживать в каталоге и Read API. Час
|
||
объекта берётся по началу, иначе принадлежность объекту зависела бы от
|
||
длительности.
|
||
|
||
`HKObject.uuid` дал бы идентичность даром, но в выгрузку Apple он не попадает —
|
||
там у записи только `type`, `sourceName`, `sourceVersion`, `creationDate`,
|
||
`startDate`, `endDate`, `value`. Значит модель обязана выражаться через
|
||
`start`/`end`, иначе `import(экспорт)` не сойдётся с `replay(HAE)`.
|
||
|
||
Мы дважды пробовали адресовать точку хешем её содержимого и дважды получали
|
||
задвоение на живых данных:
|
||
|
||
- **числа сериализуются нестабильно** — 45 507 из 71 730 повторно приехавших
|
||
точек различались последним разрядом double (`0.09523182962471353` против
|
||
`…52`), то есть 63% повторов выглядели новыми (находка 30);
|
||
- **`source` нестабилен** — то же измерение с тем же значением приезжает то
|
||
как `Apple Watch Ultra 3|iPad (Anton)`, то как `Apple Watch Ultra 3`:
|
||
Health переосмысливает атрибуцию задним числом. Минутный слой за 31 июля
|
||
оказался задвоен целиком, 120 точек в часе вместо 60 (находка 36).
|
||
|
||
Хеш при этом остаётся — но как **детектор изменений**, а не как ключ: совпал с
|
||
сохранённым, значит писать нечего. Считается он по канонической форме с
|
||
рекурсивной сортировкой ключей и округлением чисел до ~12 значащих цифр
|
||
(иначе, см. выше, «изменилось» будет срабатывать всегда).
|
||
|
||
Объект целиком тоже адресуется хешем — им сравниваются часовые пачки, чтобы
|
||
широкий проход не переписывал неизменившееся.
|
||
|
||
**Запись — слияние, а не вставка.** Приход новых точек за уже существующий час
|
||
означает: прочитать объект, влить точки (объединение по хешу точки),
|
||
отсортировать по времени, записать обратно. Точки из объекта не удаляются
|
||
никогда.
|
||
|
||
**`sealed`** отмечает часы, которые уже не должны меняться (старше окна
|
||
досчёта). Изменение запечатанного объекта — не отказ, а **сигнал**: пишем
|
||
`WARN` и всё равно сохраняем. Так мы узнаём реальную глубину досчёта из
|
||
эксплуатации, а не из предположений.
|
||
|
||
#### Разрешение столкновений
|
||
|
||
По одним координатам приезжают разные содержимые: 2 897 случаев из 444 256
|
||
координат, 0.65% (находка 49). Выигрывает **более полная** точка, и полнота —
|
||
это сравнение **множеств** ключей с непустым значением, а не их числа.
|
||
|
||
Число сравнимо всегда и потому отвечает там, где ответа нет: точка
|
||
`{"qty":0,"a":0,"b":0,"c":{},"d":[]}` несла «пять значащих полей» против двух у
|
||
настоящего измерения и стирала его безвозвратно. Множества дают три исхода
|
||
вместо одного — надмножество, равенство, несравнимость, — и только первый
|
||
означает «полнее».
|
||
|
||
Пусто — `null`, пустая строка, нулевое число, пустой объект и пустой массив;
|
||
`false` содержателен (`isIndoor: false` — тренировка на улице). Считается по
|
||
разобранному значению, а не по байтам: иначе `0.0` и `{ }` прошли бы как
|
||
содержание. `source` не участвует — он нестабилен.
|
||
|
||
Надмножество побеждает только тогда, когда **несёт то же содержание**: значения
|
||
общих содержательных ключей должны совпасть. Иначе точки несут разные
|
||
измерения, и надмножество имён о полноте не говорит ничего — пара уходит в
|
||
тай-брейк. Без этого условия `{date, qty:0.001, p1:null, p2:null}` вытесняло бы
|
||
`{date, qty:72.5}`, то есть точка без единого измерения стирала бы измерение.
|
||
|
||
Разрядов сравнения два: сперва ключи с содержанием, при их равенстве (и
|
||
совпадении значений) — все ключи. Второй разряд бережёт поля, которые не несут
|
||
содержания, но и теряться не должны: `{date, qty:10, Min:0, Max:0}` не
|
||
проигрывает `{date, qty:10}` по жребию. Несравнимость на втором разряде исходом
|
||
не является: лишние ключи там заведомо пусты, объединять в них нечего.
|
||
|
||
**Победитель — функция множества кандидатов вместе с их происхождением, а не
|
||
порядка элементов на проводе.** Попарная свёртка этого не даёт: полнота —
|
||
частичный порядок, тай-брейк — тотальный, и вместе они образуют нетранзитивное
|
||
отношение победы, то есть цикл. При цикле повторная свёртка одной и той же
|
||
доставки меняет содержимое объекта. Поэтому кандидаты координаты собираются
|
||
вместе: отбрасываются превзойдённые по полноте, среди оставшихся берётся
|
||
минимум тотального порядка — сперва происхождение (пришедшая раньше
|
||
сохранённой), затем каноническая форма. Антицикловое свойство от этого не
|
||
страдает; зависимость от **порядка журнала** появляется намеренно и оплачена
|
||
отдельно (см. ниже).
|
||
|
||
**Несравнимые множества не сливаются, а считаются.** Объединение полей — самая
|
||
дорогая часть правила — на живом потоке не потребовалось ни разу (0 из 2 897),
|
||
поэтому вместо реализации стоит счётчик и `WARN` с координатами объекта. Если
|
||
событие наступит, оно будет видно, а не додумано заранее.
|
||
|
||
**Тай-брейк при равной полноте — пришедшая доставка.** Порядок канонических
|
||
форм отвергнут замером: он берёт меньшее значение в 96% случаев (находка 49) и
|
||
стоил `step_count` его рода. Значение точки в правило не входит («брать
|
||
бо́льшее» неверно для мгновенных метрик), род метрики — тоже: род есть функция
|
||
витрины, а правило, читающее собственную выдачу, перестаёт быть функцией
|
||
префикса журнала. Байтовый порядок остался тай-брейком **внутри одной
|
||
доставки**, где провенанс общий.
|
||
|
||
Цена названа вслух: правило перестало быть функцией множества и стало явной
|
||
функцией порядка журнала. Витрина остаётся свёрткой журнала ровно потому, что
|
||
порядок свёртки приведён к порядку журнала (см. «Свёртка обязана быть
|
||
детерминированной»).
|
||
|
||
**Два правила равной полноты и когда какое.** У точки и у сущности развилка
|
||
одна, а механизмы разные — вот критерий, чтобы третья единица хранения не
|
||
открывала спор заново:
|
||
|
||
| | точка | сущность (`workout`, `record`) |
|
||
| --- | --- | --- |
|
||
| разряд полноты | множества ключей с непустым значением | покрытие содержания |
|
||
| тай-брейк равной полноты | происхождение кандидата: пришедшая побеждает | хранимая позиция журнала `(received_at, id)` |
|
||
| внутри одной доставки | порядок канонических форм | он же |
|
||
| гарантия | верна, пока порядок свёртки равен порядку журнала | верна всегда |
|
||
| в остаточном окне конкурентного приёма | расходится, пишет `WARN`, лечится `reindex` | не расходится |
|
||
| почему так | провенанса у точки нет, и заводить его дорого: колонка на точку меняет формат содержимого объекта | колонка провенанса уже есть |
|
||
|
||
Правило выбора для будущего: есть где хранить позицию журнала — храним её;
|
||
негде и завести дорого — берём происхождение и обеспечиваем порядок свёртки.
|
||
|
||
### Измерение рода агрегации
|
||
|
||
<!-- канон: поведение → openspec/specs/catalog -->
|
||
|
||
Род метрики — `cumulative`, `instant` или `unknown` — выводится сверкой
|
||
минутного слоя с часовым. Правило целиком:
|
||
|
||
```
|
||
час пригоден, если
|
||
у метрики есть объекты обоих слоёв за этот час
|
||
час не позже текущего времени плюс час
|
||
единицы обоих объектов совпадают
|
||
часовой объект несёт ровно одну точку, и она несёт значение
|
||
метка этой точки совпадает с началом часа
|
||
у минутного объекта не меньше двух точек со значением
|
||
сумма минутных отличима от их среднего
|
||
|
||
вердикт пригодного часа
|
||
часовое ≈ сумма минутных → cumulative
|
||
часовое ≈ среднее минутных → instant
|
||
иначе → свидетельства нет
|
||
|
||
вердикт метрики
|
||
≥3 согласных часа и ни одного противоречащего → род
|
||
иначе → unknown
|
||
```
|
||
|
||
Измерено на живом архиве (123 доставки, 31 метрика): 7 накопительных,
|
||
9 мгновенных, 15 неизвестных, **противоречащих часов ноль**. Каталог собирается
|
||
за 68 мс.
|
||
|
||
**Горизонт обязателен, и это условие корректности, а не защита от вредителя.**
|
||
Час объекта берётся из метки в теле доставки, а тело не наше: без верхней
|
||
границы одна доставка с метками в будущем занимает окно целиком и подменяет
|
||
измеренный род — путь построен и прогнан, мгновенная метрика объявлялась
|
||
накопительной при нуле противоречащих часов. Данные, помеченные будущим, пишутся
|
||
`WARN`: сбитые часы телефона и чужое тело в приёме лечатся не кодом.
|
||
|
||
**Единицы обеих сторон обязаны совпасть.** Мгновенная метрика в `count/min`
|
||
минутным слоем и в `count/hour` часовым даёт в полном часе
|
||
`часовое = 60 · среднее = сумма`, то есть уверенный ложный `cumulative`.
|
||
Единогласие такого случая не ловит: противоречия нет, есть молчание.
|
||
|
||
Каждая часть правила стоит своей причины.
|
||
|
||
**Различимость суммы и среднего — не украшение.** В часе, где все значения нули,
|
||
сумма равна среднему, и «сходится с суммой» выполняется тождественно: без этого
|
||
условия `walking_asymmetry_percentage` давала 4 часа «накопительная» против
|
||
3 «мгновенная», причём конфликт целиком состоял из нулевых часов.
|
||
|
||
**Выравнивание часовой метки закрывает получасовые пояса.** Слой выводится по
|
||
выравниванию метки в исходной зоне, а объект адресуется часом UTC: в зоне
|
||
`+0530` часовая точка попадает на середину часа UTC и описывает не тот интервал,
|
||
который покрывают минутные точки того же объекта.
|
||
|
||
**Единогласие, а не большинство.** Противоречащий час означает, что одна из
|
||
гипотез для метрики ложна; большинство голосов объявляло бы род при известном
|
||
контрпримере. Измеренная цена — ноль. Наличие противоречащих часов пишется
|
||
`WARN`: род — свойство, на котором Read API строит арифметику года.
|
||
|
||
**Порог в три часа** — потому что один совпавший час остаётся свидетельством
|
||
одного часа. Цена измерена: порог уводит в `unknown` метрики с единственным
|
||
согласным часом.
|
||
|
||
**Допуск сравнения — относительный, `1e-9`, и один на все три сравнения.**
|
||
Разные допуски у «сходимости» и «различимости» породили бы час, подтверждающий
|
||
обе гипотезы, и его исход определил бы порядок веток кода. Величина названа
|
||
числом, потому что от неё зависят счётчики основания в ответе: вердикты
|
||
одинаковы при допуске от `1e-9` до `1e-3`, а число согласных часов у
|
||
`heart_rate` при этом меняется вдвое. Абсолютного порога нет: около нуля
|
||
относительное сравнение вырождается в сторону «не сходится», то есть даёт
|
||
«свидетельства нет», а не ложный род.
|
||
|
||
**Родов два, а не четыре.** HealthKit различает `cumulative`,
|
||
`discreteArithmetic`, `discreteTemporallyWeighted` (пульс) и
|
||
`discreteEquivalentContinuousLevel` (аудиоэкспозиция). Взять весь словарь
|
||
напрашивалось и отвергнуто измерением: часовой слой HAE считается
|
||
арифметически, а не по Apple. Прямое свидетельство — `environmental_audio_exposure`,
|
||
которую Apple усредняет логарифмически: её часовое значение сходится с обычным
|
||
арифметическим средним минутных. Стили, которые в наших данных ничем не
|
||
проявляются, можно было бы только разметить руками — то есть вернуться к тому,
|
||
от чего уходит вся конструкция.
|
||
|
||
**Род нигде не хранится**, а считается на запрос по окну в 48 самых свежих
|
||
общих часов. Хранимое значение было бы вторым производным состоянием рядом с
|
||
витриной: его пришлось бы пересчитывать после каждой свёртки, вносить в перечень
|
||
непереносимого пересборкой и объяснять, на каком составе данных оно снято, —
|
||
причём устаревшее выглядело бы ровно как свежее. Вычисленный на запрос род есть
|
||
функция витрины, а витрина — функция журнала.
|
||
|
||
Следствие принято вслух: род есть функция окна, поэтому час, въехавший в окно,
|
||
может сменить объявленный род без единой новой доставки за спрошенный период.
|
||
Поэтому каталог отдаёт род **вместе с основанием** — сколько часов сравнено,
|
||
сколько пригодно, сколько согласны и противоречат, на каких границах окна.
|
||
|
||
**Второй предел названный вслух: окно измеряется в общих часах, а не в часах
|
||
календаря.** Выключи минутную автоматизацию — множество общих часов перестаёт
|
||
пополняться, и окно замирает на последних сорока восьми, когда она ещё работала.
|
||
Род продолжает объявляться, и единственный след этого — `last_hour` в ответе.
|
||
Календарного ограничения нет намеренно: оно уводило бы в `unknown` редкие
|
||
метрики, у которых общие часы копятся месяцами, — то есть лечило бы честный
|
||
случай ценой другого честного.
|
||
|
||
#### Как это решают другие и почему не подошло
|
||
|
||
Prior art здесь обширный, и весь он про **объявление** рода, а не про измерение.
|
||
|
||
- **HealthKit** зашивает `HKQuantityAggregationStyle` в тип метрики, а
|
||
`HKStatistics` возвращает `nil` на свёртку, не отвечающую стилю. Второе взято
|
||
как принцип («род не тот — свёртки нет»), первое неприменимо: HAE тип не шлёт.
|
||
- **Home Assistant** получает `state_class` от интеграции и при его смене
|
||
требует **удалить** долгосрочную статистику вручную. Взято признание, что
|
||
смена рода — событие, а не уточнение поля; отвергнуто объявление: объявить
|
||
некому.
|
||
- **Graphite** выводит `aggregationMethod` регуляркой по имени метрики.
|
||
Отвергнуто: противоречит инварианту «форма Apple не транслируется» и не
|
||
работает на именах HAE вовсе.
|
||
- **Prometheus и остальные** принимают тип от отправителя; заголовок HAE врёт
|
||
уже про слой, оснований верить ему про род нет. Детекция сброса счётчика
|
||
(`rate`, `total_increasing`) отвечает на другой вопрос — «был ли рестарт у
|
||
известного счётчика», — и к данным Apple неприменима: монотонного накопителя в
|
||
них нет.
|
||
- **`xFilesFactor`** (Graphite) и **`xff`** (RRDtool) — доля заполненности, ниже
|
||
которой свёртка не делается. Измерению порог не нужен: у него две
|
||
конкурирующие гипотезы, и неполный час не сходится ни с одной сам собой (у
|
||
`step_count` 41 час пригоден и 24 дали вердикт — остальные и есть неполные).
|
||
Свёртке в ответе порог понадобится, и вместе с ним выбор полярности: Graphite
|
||
задаёт долю **обязательно известных** (0.5 при роллапе и 0 при рендере),
|
||
RRDtool — долю **допустимо неизвестных**, то есть ровно наоборот. Обе величины
|
||
выглядят как «0.5», означая противоположное. Решение принимает задача Read API.
|
||
|
||
Готовой практики вывода рода **из данных** не нашлось ни одной: у всех
|
||
перечисленных есть привилегия, которой нет у нас — поставщик объявляет тип на
|
||
входе, — и все за неё платят (Prometheus теряет тип на remote write, Home
|
||
Assistant требует ручного удаления статистики). Мы платим измерением.
|
||
|
||
### Категориальные значения
|
||
|
||
<!-- канон: поведение → openspec/specs/parsing -->
|
||
|
||
HAE отдаёт перечислимые значения строками из локали телефона, а не кодами:
|
||
фаза сна приезжает как «БДГ», контекст пульса — как «Сидячий образ жизни», тип
|
||
тренировки — как «В помещении Ходьба» (машинная калька с `Indoor Walk`). При
|
||
этом `stateOfMind` в том же пакете шлёт честные коды HealthKit
|
||
(`momentary_emotion`, `slightly_pleasant`, `drained`) — значит дело не в
|
||
приложении, а в том, что старые секции тянут строки из UI (находка 37).
|
||
|
||
Оставить как есть нельзя по трём причинам, и третья решающая:
|
||
|
||
1. Клиент вынужден угадывать словарь вместо того, чтобы сравнивать с кодом.
|
||
2. Смена языка телефона молча расколет историю: та же фаза сна станет другим
|
||
значением, и по координатному ключу это неотличимо от изменения данных.
|
||
3. **Родной экспорт Apple говорит кодами** (`HKCategoryValueSleepAnalysisAsleepREM`).
|
||
Он объявлен источником истины, и на нём держится ретеншен нижнего слоя —
|
||
но сверить покрытие по этим полям было бы нечем.
|
||
|
||
Поэтому строка **хранится дословно, а рядом кладётся выведенный код** —
|
||
отдельной строкой реестра `category_value`, а не полем внутри точки:
|
||
|
||
```
|
||
category_value sleep_analysis / value / "БДГ" → HKCategoryValueSleepAnalysisAsleepREM
|
||
точка {"date": …, "value": "БДГ", …} ← не тронута
|
||
```
|
||
|
||
Рядом, а не внутри, по трём причинам: точка хранится исходными байтами и
|
||
дописать в неё ключ можно только пересериализацией; параллельный массив кодов в
|
||
`bucket` завёл бы производную величину в путь слияния и хеширования; пополнение
|
||
словаря переписывало бы каждый объект с фазами сна. Обоснование целиком — в
|
||
[журнале решений](adr/README.md).
|
||
|
||
Ключ реестра — `(метрика, поле, значение)`. Словарь при этом ключуется парой
|
||
`(локаль, строка)`, локаль берётся из `Accept-Language` (находка 32) и **в ключ
|
||
реестра не входит**: заголовков в сыром архиве нет, и ключ с локалью сделал бы
|
||
состояние функцией от того, уцелела ли учётная строка. Локаль сужает поиск; её
|
||
отсутствие вывода не отменяет, если строка однозначна по всем локалям.
|
||
|
||
Словарь и таблица синонимов кодов живут в бинаре (`internal/healthkit`), а не в
|
||
базе: словарь, наполняемый руками, стал бы входом, которого нет в журнале, и
|
||
`import + replay` перестал бы задавать состояние однозначно. Синонимы нужны
|
||
потому, что коды тоже не вечны: Apple переименовала `…Asleep` в
|
||
`…AsleepUnspecified` и переписывает историю при выгрузке (находка 43).
|
||
|
||
Для незнакомой строки код пустой — пустота честнее догадки, и перечень таких
|
||
строк в реестре есть заявка на пополнение словаря. Счётчик неизвестных строк
|
||
уходит в лог свёртки числом; сами строки — данные о здоровье и в лог не
|
||
попадают.
|
||
|
||
Дословность инварианта не нарушена: код **приписывается**, а не подменяет
|
||
строку. Обратное преобразование всегда возможно.
|
||
|
||
Реестр — единица хранения витрины и входит в отпечаток **наблюдением**, но не
|
||
выведенным кодом: код производен от словаря в бинаре, а не от журнала, и в
|
||
отпечатке он превратил бы всякое пополнение словаря в расхождение при совпавшем
|
||
журнале.
|
||
|
||
### Тренировки и прочие секции
|
||
|
||
<!-- канон: поведение → openspec/specs/parsing -->
|
||
|
||
Тренировка адресуется своим `id` из HealthKit, запись — парой `род + id`.
|
||
`record` держит секции с собственными идентификаторами; разбором покрыт пока
|
||
только `stateOfMind`, а `ecg`, `symptoms`, `cycleTracking`, `medications` и
|
||
`heartRateNotifications` остаются непокрытыми **намеренно**: живой поток не
|
||
приносил их ни разу, их формы никто не видел, а полнота покрытия HealthKit ради
|
||
полноты целью проекта не является. Модель под них заложена: **миграции схемы**
|
||
новая секция не требует — она добавляется строкой в множество покрытых имён.
|
||
Но не одной: правило «покрыли секцию — пересверните» (выше) требует ещё и
|
||
data-миграции, переводящей уже принятые `partial`-доставки с этим ключом в
|
||
`pending`, а после появления ретеншена — строки в перечне невосстановимого.
|
||
Три места, и первое из них — не самое важное.
|
||
|
||
Ключ записи — **пара**, а не один `id`: собственный `id` наблюдался живьём
|
||
только у `stateOfMind`, где он UUID, и короткий несквозной идентификатор в двух
|
||
разных секциях затёр бы одну запись другой молча.
|
||
|
||
Пачками они не хранятся: у них есть естественный ключ, они редки, и
|
||
группировать их по часам незачем.
|
||
|
||
**Тренировка не разворачивается.** Заголовок — колонками, всё остальное,
|
||
включая маршрут и внутренние ряды, — блобом `payload`. Структура тренировки
|
||
разнородна и избыточна (сводки дублируют ряды, находка 15); раскладывать её в
|
||
таблицы значило бы решить за Apple, что в ней главное. Колонок ровно столько,
|
||
сколько нужно выборке: имя, интервал, офсет зоны, длительность. Длительность
|
||
берётся из тела, а не считается как `end - start` (HAE шлёт 91.746 при
|
||
интервале в 91 секунду), и её отсутствие выражается пустотой, а не нулём — ноль
|
||
законная длительность.
|
||
|
||
**Пульс приедет дважды** — в общем потоке метрики `heart_rate` и внутри
|
||
объекта тренировки. Это ожидаемо, они лежат в разных таблицах и не
|
||
смешиваются.
|
||
|
||
**Значение заголовка не того типа стоит одного поля, а не сущности.** Пять полей
|
||
(`id`, `name`, `date`, `start`, `end`) читаются мягко: нестроковое значение
|
||
считается неприсланным. Иначе `name`, приехавшее числом, уносит тренировку
|
||
вместе с маршрутом, а доставка при этом числится разобранной. Мягкость сделана
|
||
через `json.Unmarshaler`, а не через разбор ошибки типа постфактум: библиотека
|
||
дозаполняет поля «как может», но не обязуется дозаполнить те, что стоят **после**
|
||
проблемного, — то есть исход перестал бы быть функцией тела.
|
||
|
||
Исключений два, и оба названы. `id`: без строкового идентификатора сущность не
|
||
адресуема, а приведение чужого значения к строке было бы выдумыванием
|
||
идентичности за источник. `start`: непонятое значение не откатывается на `date` —
|
||
подстановка другого поля дала бы метку **другого момента времени**, неотличимую
|
||
от настоящей и ничем не считаемую.
|
||
|
||
**Граница правила: оно закрывает смену типа, но не смену формата строки.** А
|
||
наблюдался именно дрейф формата дат. Тренировка с датой в незнакомом формате
|
||
по-прежнему теряется целиком; закрыть это может только хранение сущности с
|
||
неразобранной меткой, и это отдельная задача. Пропуск при этом перестал быть
|
||
невидимым: число пропущенных сущностей лежит в учётной записи доставки, и
|
||
ретеншен, решающий «что потеряется, если тело удалить», больше не получает
|
||
ложное «терять нечего». Отсутствие значения в этой колонке означает «не
|
||
измерялось» и нулю не равно.
|
||
|
||
#### Замена версии сущности
|
||
|
||
«Перезаписывается» уточнено измерением. Тренировка приезжает повторно каждой
|
||
доставкой, пока источник её досчитывает: на живом архиве одна тренировка
|
||
приехала 26 раз в трёх различных содержимых — сперва добавились `stepCadence` и
|
||
`stepCount` вместе с изменившимся рядом `activeEnergy`, затем при том же наборе
|
||
полей досчитались `totalEnergy` и `basalEnergy`. То есть тренировка правится
|
||
задним числом ровно так же, как минутное ведро (находка 10), а набор её полей
|
||
за весь корпус ни разу не уменьшился.
|
||
|
||
Правило:
|
||
|
||
```
|
||
1. каноническая форма совпала с сохранённой → записи нет (хеш-детектор)
|
||
2. приехавшая несёт всё содержание сохранённой
|
||
и сверх того → приехавшая замещает целиком
|
||
3. приехавшая теряет содержание сохранённой → остаётся сохранённая,
|
||
счётчик + WARN
|
||
4. содержание равно → версия из более поздней
|
||
доставки ЖУРНАЛА
|
||
5. наборы несравнимы → остаётся сохранённая,
|
||
счётчик + WARN
|
||
```
|
||
|
||
**Содержание сравнивается множествами ключей и формой их значений — но не
|
||
значениями.** Правило полноты, принятое для точек, здесь неприменимо, и это
|
||
проверено выполненной командой: оно гасит отношение включения до «равенства»,
|
||
когда значения общих ключей разошлись, — а у сущности они расходятся всегда.
|
||
Обеднённая версия получила бы «равенство» и заместила бы сохранённую вместе с
|
||
маршрутом, причём тест на фикстуре с неизменёнными значениями остался бы
|
||
зелёным.
|
||
|
||
Условий покрытия четыре, все по **верхнему уровню**:
|
||
|
||
```
|
||
1. каждый содержательный ключ сохранённой есть у приехавшей и содержателен
|
||
2. каждый ключ сохранённой, даже пустой, есть у приехавшей
|
||
3. форма не вырождается: объект остаётся объектом, массив — массивом
|
||
4. верхнеуровневый массив не теряет ни длины, ни содержательных элементов
|
||
```
|
||
|
||
Условие 2 — тот же второй разряд, что у точек, и с тем же **условием**: оно
|
||
включается только при равенстве множеств содержательных ключей. Иначе ключ с
|
||
пустым значением исчезает по жребию тай-брейка — но и обратная крайность
|
||
проверена оракулом и отвергнута: безусловный второй разряд запирал законный
|
||
досчёт навсегда. Версия с `totalEnergy: null` и без маршрута оказывалась
|
||
несравнимой с версией, у которой маршрут приехал, а этого ключа нет, — и
|
||
маршрут не доезжал **никогда**, причём пересборка проигрывала то же поражение.
|
||
Второй разряд разрешает спор равных, а не отменяет первый.
|
||
|
||
Условие 3 закрывает «скелет»: тело, где каждый вложенный объект заменён числом,
|
||
а каждый массив — массивом той же длины из `null`, проходило все прежние
|
||
проверки и по тай-брейку журнала замещало настоящую тренировку целиком.
|
||
|
||
Условие 4 добавлено потому, что усечённый маршрут (три точки вместо 593) ключа
|
||
не теряет, а маршрут из `[null,null,null]` не теряет и длины — притом что
|
||
маршрут это 95% веса тренировки. Содержательность элемента — **та же пустота**,
|
||
что у поля точки; второй словарь пустоты дал бы два ответа на один вопрос. Цена
|
||
названа вслух: ряд настоящих нулей (`[0,0,0]`) считается лишённым содержания, и
|
||
версия с ним сохранённую не заместит. Ошибка направлена в безопасную сторону —
|
||
правило удерживает, а не затирает, — и видна счётчиком.
|
||
|
||
Условия 3 и 4 применяются к ключам, содержательным у сохранённой: у пустоты
|
||
формы нет, и требовать её сохранения значило бы отличать `[]` от `0` там, где ни
|
||
то, ни другое ничего не несёт.
|
||
|
||
Поле `source` в множества не входит — ни у точки, ни у сущности. Для точки
|
||
причина измерена (оно нестабильно и переписывается задним числом, находка 36);
|
||
для сущности она наследуется, и это сказано вслух, потому что список исключений
|
||
живёт в общем разборе: правка ради точек молча изменит правило удержания
|
||
сущностей. Верхнеуровневого `source` ни у тренировки, ни у `stateOfMind` живьём
|
||
не наблюдалось.
|
||
|
||
**Предел правила назван вслух и не закрывается: сокращение внутри элемента ряда
|
||
(точка маршрута без `altitude` при непустом элементе и той же длине) не ловится
|
||
ничем, кроме сверки с телом в архиве.** Поэлементная сверка содержимого
|
||
отвергнута ценой: она разворачивала бы каждый элемент маршрута в дерево
|
||
значений на каждое сравнение, а тело 40 МиБ уже даёт 768 МиБ пика.
|
||
|
||
**Проверить это правило отпечатком нельзя.** Живой приём и пересборка пользуются
|
||
одним правилом и одинаково сойдутся на одинаково удержанной версии — то есть
|
||
слишком строгое правило, замораживающее тренировку на старой версии, выглядело
|
||
бы идеальной сходимостью. Поэтому число удержаний идёт в отчёт пересборки и
|
||
печатается всегда, включая ноль: здесь ноль это утверждение, а не отсутствие
|
||
новостей.
|
||
|
||
**Тай-брейк при равном содержании — позиция доставки в журнале
|
||
`(received_at, id)`, а не порядок свёртки.** Напрашивавшееся «побеждает
|
||
приехавшая» отвергнуто: приехавшая есть функция порядка свёртки, а он порядку
|
||
журнала не равен (см. «Предел порядка назван вслух»). Доставка с более ранней
|
||
меткой, свёрнутая позже, вернула бы витрину к недосчитанной версии, и
|
||
пересборка разошлась бы с живым приёмом **молча** — в содержимом тренировки, где
|
||
это не видно ничем, кроме отпечатка. Поэтому сущность несёт провенанс:
|
||
доставку своей версии и её метку приёма. Тай-брейк по канонической форме (как у
|
||
точек) отвергнут по другой причине: он заморозил бы тренировку на произвольной
|
||
из версий навсегда, вместе с недосчитанной энергией.
|
||
|
||
Провенанс поднимается **и при совпавшем хеше**. Совпал хеш — содержимое то же,
|
||
писать нечего; но сохранённая позиция журнала участвует в тай-брейке пункта 4, и
|
||
если в ней осталась первая свёрнутая копия вместо победителя журнала, отложенная
|
||
доставка вернёт витрину к прежнему содержимому — то есть живая витрина
|
||
разойдётся с пересборкой молча. Обновляется только провенанс: метка изменения
|
||
содержимого не двигается, иначе она становится меткой касания строки и дребезжит
|
||
двадцать шесть раз на неизменившейся тренировке, а запрос «что изменилось с
|
||
момента X» получает шум, неотличимый от настоящего досчёта.
|
||
|
||
Слово «провенанс» у сущности и у часового объекта значит **разное**, и это
|
||
сказано вслух: у объекта хранится доставка, **создавшая** его, и она не
|
||
поднимается никогда; у сущности — доставка, **чья версия лежит сейчас**, и она
|
||
поднимается до максимума по журналу среди версий с этим содержимым. У объекта
|
||
нет замещения версии целиком, у сущности только оно и есть.
|
||
|
||
Версии одного ключа **внутри одной доставки** позициями не различаются, и
|
||
победитель среди них — **функция множества**, а не порядка элементов массива:
|
||
отбрасываются строго покрытые (покрыта другой и сама её не покрывает —
|
||
покрытие предпорядок, и наивное «выбросить всё покрытое» опустошило бы
|
||
множество), среди оставшихся берётся минимум канонической формы, а при равных
|
||
формах — минимум исходных байтов. Последний разряд не украшение: у сущностей
|
||
версии с равной формой не схлопываются, а порядок ключей в JSON от HAE
|
||
нестабилен — без него в витрину легли бы разные байты при одинаковом содержимом.
|
||
Механизм тот же, что у точек, и живёт он одним помощником на обе единицы
|
||
хранения: попарная свёртка здесь уже давала нетранзитивную победу, при которой
|
||
`[A,B,C]` и `[B,C,A]` выбирали разных победителей.
|
||
|
||
Отвергнут и **голый upsert по `id`** (так делает сервер HealthyApps поверх
|
||
MongoDB, и так просилось из слова «перезаписывается»): единственный наблюдённый
|
||
сценарий повторной присылки — рост, но маршрут стоит 95% содержимого, а
|
||
восстановление требует пересборки всего журнала. Условие пункта 3 стоит одного
|
||
сравнения множеств и делает событие наблюдаемым вместо необратимого.
|
||
|
||
Остаточный предел назван вслух: сравнение сохранённой с приехавшей попарно —
|
||
в витрине лежит победитель прошлых слияний, а не все кандидаты истории, —
|
||
поэтому при несравнимых наборах (пункт 5) исход зависит от порядка
|
||
проигрывания. Тот же предел есть у часового объекта. **Это единственная точка,
|
||
где витрина не является функцией множества доставок**, и потому утверждение
|
||
«перестановка порядка свёртки даёт один отпечаток» верно ровно при нулевом
|
||
счётчике несравнимых версий; при ненулевом расхождение законно и обязано идти
|
||
вместе с этим счётчиком.
|
||
|
||
Второй разряд условия покрытия делает пункт 5 чаще, чем он был: версия, принёсшая
|
||
новые содержательные ключи и потерявшая пустой, теперь несравнима вместо
|
||
«полнее». Плата принята сознательно — она направлена в сторону удержания, а не
|
||
затирания, — и её величину показывает счётчик удержаний в отчёте пересборки.
|
||
|
||
#### Отпечаток и отчёт пересборки идут за витриной
|
||
|
||
Отпечаток покрывает **все** единицы хранения и снимается одной транзакцией
|
||
чтения: отпечаток одних часовых объектов давал бы «состояние сошлось» при
|
||
разъехавшихся тренировках, а три запроса вне общей транзакции под живым приёмом
|
||
дали бы смесь снимков и ложное «разошлись». Отчёт `reindex` считает «было и
|
||
стало» по каждой единице и называет «покрыта новая секция» ожидаемым классом
|
||
расхождения — иначе первый прогон после такого изменения расходится
|
||
гарантированно, а человек читает это как дефект.
|
||
|
||
#### Предел, который придётся закрыть импортом
|
||
|
||
В `export.xml` у элемента `Workout` идентификатора нет вовсе —
|
||
`dogsheep/healthkit-to-sqlite` поэтому адресует тренировку **хешем содержимого**
|
||
(`hash_id` в sqlite-utils). Значит `import` снапшота задвоит тренировки,
|
||
приехавшие от HAE: та же дыра, что у точек, где её закрыли ключом
|
||
`start + end`. Сегодня импорта нет, и решать это до его формы значило бы
|
||
угадывать; предел записан в беклоге отдельной задачей.
|
||
|
||
### Время
|
||
|
||
Точка внутри объекта хранится дословно, вместе с исходной строкой даты. Для
|
||
адресации и выборок используется нормализованное время: `hour_utc` у объекта,
|
||
`ts_utc` + офсет исходной зоны у записей с собственным ключом. Офсет нужен,
|
||
чтобы клиент мог считать сутки и по UTC, и по местному времени: без него
|
||
суточные ряды незаметно поехали бы после смены часового пояса.
|
||
|
||
**Формат даты зависит от секции пакета**: метрики и тренировки шлют
|
||
`2026-07-31 21:03:51 +0300`, `stateOfMind` — RFC 3339 в UTC (`…T18:03:51Z`).
|
||
Одного парсера недостаточно (находка 16).
|
||
|
||
## Read API
|
||
|
||
```
|
||
GET /api/v1/metrics каталог: имя, units, род, слои с диапазонами
|
||
GET /api/v1/metrics/{name}?from&to&layer точки метрики за период (bucket — соседняя задача, пока 400)
|
||
GET /api/v1/workouts?from&to заголовки тренировок
|
||
GET /api/v1/workouts/{id} тренировка целиком, с маршрутом
|
||
GET /api/v1/records/{kind}?from&to прочие секции
|
||
GET /api/v1/schema схемы всего, что есть в хранилище
|
||
GET /api/v1/metrics/{name}/schema схема и статистика одной метрики
|
||
GET /stats последняя доставка, счётчики, тишина по потоку
|
||
GET /healthz
|
||
```
|
||
|
||
Хранение пачками на контракт не влияет: `GET /metrics/{name}` собирает ответ
|
||
из часовых объектов, попавших в диапазон, и отдаёт точки. Клиент про объекты
|
||
не знает — это деталь хранения, а не API.
|
||
|
||
**Слои, наоборот, часть контракта.** Каталог показывает, какие разрезы есть и
|
||
за какой период:
|
||
|
||
```json
|
||
{"metric": "heart_rate", "units": ["count/min"],
|
||
"aggregation": {"style": "instant", "hours": 48, "compared": 48,
|
||
"agreeing": 20, "conflicting": 0,
|
||
"first_hour": "2026-07-31T09:00:00Z",
|
||
"last_hour": "2026-08-02T14:00:00Z"},
|
||
"layers": [
|
||
{"layer": "minute", "from": "2026-07-25T00:01:00Z", "to": "2026-08-01T23:59:00Z", "points": 14203},
|
||
{"layer": "raw", "from": "2026-07-30T00:00:07Z", "to": "2026-08-01T23:59:58Z", "points": 2078}
|
||
]}
|
||
```
|
||
|
||
`aggregation.style` — измеренный род (`cumulative` / `instant` / `unknown`), от
|
||
него зависит, что вообще можно спросить. Рядом лежит **основание**: сколько
|
||
общих часов попало в окно, сколько из них оказалось пригодными, сколько дали
|
||
преобладающий вердикт и сколько противоречили. Одного числа не хватало —
|
||
«часов было 48, а пригодным не оказалось ни одного» и «часов не было вовсе»
|
||
разные события, и различать их клиент обязан без второго запроса. Поле названо
|
||
`style`, а не `kind`: `kind` в проекте уже занят родом секции записи.
|
||
|
||
`units` — множество: единицы на живом потоке не менялись ни разу (находка 48),
|
||
но одна форма поля для обоих случаев честнее строки, которая при расхождении
|
||
молча выберет одно из двух. На слой при этом приходится ровно один элемент
|
||
`layers`.
|
||
|
||
Границы слоя — метки **первой и последней точки**, включительно; `first_hour` и
|
||
`last_hour` — **ярлыки часов** окна измерения. Имена разные потому, что разная
|
||
семантика: одно имя для двух смыслов в одном ответе стоило бы клиенту ошибки на
|
||
час, заметной только расхождением сумм.
|
||
|
||
Границы слоя — это границы **данных, а не обещание покрытия**: внутри диапазона
|
||
законно есть дыры. Поэтому правило выбора слоя опирается на фактические объекты
|
||
запрошенного диапазона, а не на каталожную пару границ.
|
||
|
||
Параметр `layer` выбирает разрез. Если он не указан — берём слой с **наибольшим
|
||
охватом внутри запрошенного периода**, а при равном охвате самый мелкий (порядок
|
||
`sample` → `raw` → `minute` → `hour` → `day`). Молча переключать слой на границе
|
||
периода нельзя: ряд поедет незаметно для клиента, и ряд из одного ответа всегда
|
||
собран из одного слоя.
|
||
|
||
**Охват — длина пересечения** отрезка «первая метка слоя … последняя метка слоя»
|
||
с периодом; слой с пустым пересечением выбывает. Меряется он метками **точек**,
|
||
а не часами объектов. Почему прежняя формулировка («самый мелкий, покрывающий
|
||
весь диапазон») пересмотрена, почему мера именно такая и во что она обошлась —
|
||
[ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek](adr/ADR-2026-08-04-sloy-vybiraetsya-po-ohvatu-tochek.md).
|
||
|
||
Словарь слоёв при этом **один** (`hae.Layers`): из него выводятся и порядок, и
|
||
перечень слоёв в выборке охватов, и проверка параметра запроса, и текст отказа
|
||
клиенту.
|
||
|
||
### Условный запрос
|
||
|
||
Ресурсы чтения отвечают `304 Not Modified` на `If-None-Match` с непротухшей
|
||
меткой и **не открывают снимок витрины вовсе**.
|
||
|
||
**Метка собирается из всего, от чего зависит ответ.** У каталога это версия
|
||
витрины (см. «Версия витрины и обслуживание журнала») и **горизонт измерения**:
|
||
горизонт едет вместе с часами, и метка из будущего, лежащая в витрине, въезжает
|
||
в окно сама, без единого коммита. Путь построен враждебным проходом ревью и
|
||
прогнан: та же версия витрины, `cumulative` против `unknown`. Горизонт входит в
|
||
метку огрублённым до часа — огрубление точное, потому что метки объектов лежат
|
||
ровно на часах; цена — один полный ответ в час на потребителя.
|
||
|
||
Форма метки **слабая** (`W/"…"`): она выведена из состояния, а не из байтов
|
||
ответа — так предписывает общая практика для валидаторов такого рода. На исход
|
||
`304` это не влияет, `If-None-Match` сравнивается слабо в любом случае.
|
||
|
||
**Область действия метки — часть самой метки.** Она действительна в пределах
|
||
одного ресурса, поэтому маршрут, чей ответ есть функция параметров (точки), и
|
||
транспорт без адреса вовсе (MCP) кладут в неё канонизированную форму запроса.
|
||
Прозой это требовать бесполезно — прозу компилятор не проверяет, а забыть
|
||
область значит однажды ответить `304` на чужой набор данных; поэтому она
|
||
параметр помощника, а не забота вызывающего.
|
||
|
||
Три правила разбора, каждое из которых легко нарушить: неразбираемое условие
|
||
даёт `200`, а не `400`; `*` совпадает с любой **существующей** меткой, а при её
|
||
отсутствии условие не выполнено; `304` уходит без тела и без представленческих
|
||
заголовков. Токен чтения проверяется **раньше** условия: `304` без токена
|
||
подтверждал бы состояние витрины тому, кому она не открыта.
|
||
|
||
Ответы чтения помечаются `Cache-Control: private, no-cache`. До появления
|
||
валидатора эвристическое кеширование посредником было маловероятным; с меткой
|
||
ответ становится штатно кешируемым, а при выключенной проверке токенов в
|
||
запросе нет и `Authorization`.
|
||
|
||
Следствие названо вслух: **`304` не выполняет измерения и потому не пишет
|
||
предупреждений владельцу** (данные из будущего, противоречащий род). С условным
|
||
опросом они становятся функцией смены версии витрины, а не числа запросов;
|
||
состояние при этом не теряется — следующая доставка меняет версию, ответ
|
||
собирается, и предупреждение пишется.
|
||
|
||
### Свёртка и размер ответа
|
||
|
||
Запросов к метрике ровно два, и это один запрос с необязательным параметром:
|
||
|
||
```
|
||
?from&to все значения за период вес, лекарства, симптомы
|
||
?from&to&bucket=day значения с разбивкой шаги, энергия
|
||
```
|
||
|
||
Главный потребитель — агент, у которого ограничен контекст. «Пульс за неделю»
|
||
без разбивки — это десятки тысяч точек в минутном слое и сотни тысяч в
|
||
нижнем. Правило:
|
||
|
||
- **Разбивка не задана, ответ не влезает** — сервер сам берёт сетку погрубее,
|
||
чтобы влезло, и называет её в ответе. Агент всегда получает ответ и может
|
||
переспросить уже.
|
||
- **Разбивка задана явно, ответ не влезает** — это ошибка, а не тихая
|
||
подмена. В теле ошибки — число точек по каждой доступной сетке, чтобы
|
||
второй запрос был заведомо успешным.
|
||
|
||
Различие существенно: «указали уровень» работает как информация в первом
|
||
случае и как защита во втором. Иначе агент, попросивший минутную сетку,
|
||
получил бы суточные суммы и заметил бы это, только прочитав поле, которое
|
||
вполне может не прочитать.
|
||
|
||
Свёртка применяет род из каталога: `cumulative` — сумма, `instant` —
|
||
среднее с `min`/`max` рядом. При `unknown` свёртка не выполняется, а параметр
|
||
`bucket` отвергается ошибкой. Порог заполненности ведра (`xFilesFactor`) и его
|
||
полярность выбирает эта же задача — см. «Измерение рода агрегации». Накопительные метрики никогда не сворачиваются
|
||
из нижнего слоя HAE — только из `minute`, `hour` или `sample`.
|
||
|
||
### Форма ответа
|
||
|
||
Нормализованная оболочка, сырое содержимое:
|
||
|
||
```json
|
||
{"metric": "heart_rate",
|
||
"from": "2026-07-31T00:00:00Z", "to": "2026-08-01T00:00:00Z",
|
||
"layer": "minute", "bucket": null,
|
||
"aggregation": {"style": "instant", "applicable": true,
|
||
"last_hour": "2026-08-02T14:00:00Z"},
|
||
"points": [
|
||
{"ts": "2026-07-31T09:00:00Z", "ts_end": "2026-07-31T09:00:00Z",
|
||
"tz_offset": 10800, "units": "count", "values": {"qty": 812}}
|
||
]}
|
||
```
|
||
|
||
Все поля присутствуют ВСЕГДА, даже когда сообщить нечего: клиент не должен
|
||
выводить исход наличием или отсутствием поля. `bucket` равен `null`, когда
|
||
свёртки не было; `layer` — `null`, когда слой выбирала система и выбирать было
|
||
не из чего (явно запрошенный слой уезжает всегда, в том числе при пустом ряде).
|
||
|
||
`aggregation` — **объект, а не строка**. Строка называла бы только применённую
|
||
свёртку, а инвариант требует, чтобы клиент видел ещё и основание (решение и
|
||
разбор чужих API —
|
||
[ADR-2026-08-04-otvet-tochek-nesyot-rod-i-ego-primenimost](adr/ADR-2026-08-04-otvet-tochek-nesyot-rod-i-ego-primenimost.md)):
|
||
|
||
- `style` — измеренный род метрики, тот же словарь, что у каталога;
|
||
- `applicable` — применим ли род к **отданному ряду**. Род есть свойство
|
||
метрики, слой — свойство ряда, и сочетание `{"layer": "raw", "style":
|
||
"cumulative"}` законно и штатно: оно приглашает потребителя сложить
|
||
интерполяцию самому и завысить втрое. Система при этом не складывает ничего —
|
||
а потребитель об инварианте не знает;
|
||
- `last_hour` — ярлык самого свежего часа окна измерения. Окно считается в
|
||
**общих** часах, а не в часах календаря: выключенная минутная автоматизация
|
||
HAE останавливает их пополнение, окно замирает и продолжает объявлять род.
|
||
Это единственный след.
|
||
|
||
`ts_end` — конец координаты точки; у точки-измерения равен `ts`. Он есть потому,
|
||
что идентичность точки — интервал, а не метка: под одной меткой лежит до трёх
|
||
записей сна, и конверт с одним `ts` предлагал бы клиенту различать их, разбирая
|
||
дословное содержимое.
|
||
|
||
Принадлежность точки периоду определяется её **началом** — тем же правилом,
|
||
каким час объекта берётся по началу. Цена названа: «сон за ночь с полуночи» не
|
||
увидит эпизод, начавшийся в 23:40.
|
||
|
||
Время приведено к единому виду, значения отданы как пришли: ни
|
||
переименований, ни пересчёта единиц, ни экранирования (сериализатор ответа
|
||
HTML-символы не экранирует — иначе `&` в имени источника уезжал бы как
|
||
`\u0026`, и обещание дословности переставало быть правдой). Метрик у Apple
|
||
много и они разные — семантику разбирает клиент по имени метрики. Полная
|
||
нормализация означала бы, что каждая новая метрика требует правки коллектора,
|
||
а незнакомая теряется.
|
||
|
||
### Форма провода
|
||
|
||
**Форму ответа объявляет транспорт, а не домен.** Каждый читающий маршрут
|
||
`internal/httpapi` держит собственные типы с `json`-тегами и переводит в них
|
||
доменное значение присваиванием поле в поле; доменные типы (`internal/catalog`
|
||
и далее) `json`-тегов не несут и до сериализации не доезжают. То же правило
|
||
покрывает тело отказа. MCP собственной формы не объявляет — адаптер переводит
|
||
вызовы в те же обработчики.
|
||
|
||
Цена названа с обеих сторон, потому что она обратная, а не односторонняя.
|
||
|
||
- **Домен = провод** (как было у каталога): формы объявлены один раз, перевода
|
||
нет, ноль строк на маршрут. Платим тем, что публичный контракт меняется
|
||
правкой домена **молча** — переименованием поля, разъединением встроенной
|
||
структуры (плоскость `aggregation` была следствием встраивания `Basis`),
|
||
появлением внутреннего поля. Ни одна из трёх правок транспорт не трогает.
|
||
- **Раздельно** (взято): контракт меняется только правкой транспорта, то есть
|
||
действием. Платим двумя вещами. Форма объявлена дважды — типы и перевод на
|
||
каждый маршрут. И цена **обратная**: новое поле домена в ответ само не
|
||
попадёт, его обязан перечислить перевод; поле, не доехавшее до клиента, —
|
||
такой же дефект, как поле, уехавшее случайно, просто другой.
|
||
|
||
Развилку решил факт, а не вкус: провод точек обещан как
|
||
`{ts, tz_offset, units, values}`, а `store.Point` несёт
|
||
`{Start, End, OffsetSeconds, Raw}` — эти наборы не совпадают ни одним именем,
|
||
и доменный тип формой провода там быть не может. Хранилище, кстати, уже живёт
|
||
по этому правилу: формат `payload` объявлен отдельным неэкспортированным
|
||
`storedPoint`, а `encodePayload` переводит в него полем в поле.
|
||
|
||
Сторожей два, и роли у них разные. **Обход графа типов ответа** (внутренний
|
||
тест `httpapi`) утверждает, что домен до энкодера не доезжает — отсюда и
|
||
следует, что переименование поля домена байт не меняет; рядом стоит заведомо
|
||
красный случай, потому что проверка, доказывающая отсутствие, зелена и будучи
|
||
сломанной. **Байтовый литерал** на каждую различимую форму ответа — детектор
|
||
изменения формы: он краснеет в момент правки. Источником истины контракта он
|
||
не является — им станет рукописная OpenAPI-спека, и сверку с маршрутами внесёт
|
||
в гейт отдельная задача.
|
||
|
||
Разбор чужих решений (домен = провод у `wtf` и Prometheus; раздельно у Gitea,
|
||
Docker и go-kit; версионирование с конверсией у Kubernetes; отвергнутый
|
||
`apidiff`, который смены `json`-тега не видит вовсе) —
|
||
[design.md изменения](../openspec/changes/archive/2026-08-04-forma-provoda-chteniya/design.md).
|
||
Ссылка markdown-ссылкой намеренно: инлайн-код `docs.py check` не проверяет, а
|
||
путь угадывался до архивации.
|
||
|
||
### MCP
|
||
|
||
Поверх Read API — адаптер MCP, чтобы агент подключался без промежуточного
|
||
кода. Инструментов ровно два, по числу форм запроса выше, плюс каталог.
|
||
Собственной логики в адаптере нет: он переводит вызовы в те же обработчики.
|
||
|
||
**Транспорт — HTTP** (Streamable HTTP), не stdio: сервис живёт на VPS, и агент
|
||
ходит к нему по сети. Отсюда следствия:
|
||
|
||
- MCP — это **эндпоинт того же процесса**, а не отдельная подкоманда: тот же
|
||
бинарь, тот же порт, тот же Caddy впереди с TLS.
|
||
- Аутентификация — **тот же токен чтения** в `Authorization: Bearer`, что и у
|
||
Read API. Отдельного контура доступа не заводим: MCP не даёт ничего, чего
|
||
не даёт HTTP, и права у них обязаны совпадать.
|
||
- Правило размера ответа (см. выше) здесь не украшение, а необходимость:
|
||
сетевой агент не имеет возможности «посмотреть поближе» иначе, чем
|
||
повторным вызовом.
|
||
|
||
## Самоописание
|
||
|
||
Сервис описывает свои данные сам: клиент (в том числе AI-агент) не должен
|
||
угадывать структуру по выборке — он запрашивает схему и сразу знает, что
|
||
лежит в наборе. Слоя два.
|
||
|
||
**Схема контракта** — форма конверта, который отдаёт API (`ts`, `tz_offset`,
|
||
`units`, `values`, заголовок тренировки, ошибка). Наша, статичная, пишется
|
||
руками.
|
||
|
||
**Каталог разрезов** — какие слои есть у метрики и за какие периоды. Отвечает
|
||
на вопрос «что вообще можно спросить», прежде чем клиент спросит.
|
||
|
||
**Схема содержимого** — что лежит внутри `values` у конкретной метрики.
|
||
**Выводится из данных**, а не ведётся вручную: метрик у Apple больше сотни, и
|
||
рукописный каталог описывал бы документацию HAE, а не то, что он реально
|
||
прислал. Выведенная схема производна ровно так же, как витрина: считается тем
|
||
же проходом разбора, инкрементально при приёме и целиком при `reindex`.
|
||
Незнакомая метрика описывает себя сама, без релиза.
|
||
|
||
Схема отдаётся **вместе со статистикой** — для потребителя она важнее
|
||
формального типа:
|
||
|
||
```json
|
||
{"metric": "heart_rate", "points": 412355,
|
||
"first_ts": "2019-03-02T…", "last_ts": "2026-07-31T…",
|
||
"units": ["count/min"],
|
||
"fields": {"Min": {"type": "number", "presence": 1.0},
|
||
"Avg": {"type": "number", "presence": 1.0},
|
||
"Max": {"type": "number", "presence": 1.0}}}
|
||
```
|
||
|
||
Вывод ограничен по глубине вложенности — иначе схема тренировки с маршрутом
|
||
разрослась бы до размеров самих данных. Форма точки маршрута при этом
|
||
описывается: блоб трека не непрозрачен, это массив однотипных объектов.
|
||
|
||
## Аутентификация
|
||
|
||
Статический токен в заголовке `Authorization: Bearer …`; список допустимых
|
||
токенов — в конфиге. HAE умеет слать произвольные заголовки, этого достаточно.
|
||
|
||
Токены **раздельные**: на запись (приём) и на чтение. Клиент, читающий
|
||
данные, не может писать. MCP пользуется токеном чтения — отдельного контура
|
||
у него нет, см. «MCP».
|
||
|
||
Наружу открыты два контура: приём (телефон) и чтение вместе с MCP (агенты и
|
||
приложения). Оба через Caddy с TLS, оба с разными токенами.
|
||
|
||
## Деплой
|
||
|
||
VPS **rivendell** (Timeweb), доступен всегда. Перед сервисом — **Caddy**, он
|
||
терминирует TLS; сам сервис слушает plain HTTP. Приём открыт наружу на
|
||
отдельном поддомене — телефон должен доставать до него из любой сети, иначе
|
||
экспорт копится и уезжает пачкой при возвращении домой.
|
||
|
||
Сборка — на локальной машине: статический бинарь и docker-образ; на сервер
|
||
едет готовый образ. Go-тулчейн на сервере не нужен.
|
||
|
||
Тома: каталог сырого архива и файл SQLite — на постоянном хранении, конфиг
|
||
(с токенами) — отдельно, `0600`.
|
||
|
||
**Откат бинаря поверх новой схемы отказывает на старте.** Версия схемы базы выше
|
||
версии, вшитой в бинарь, — отказ, а не повод мигрировать; в контейнере это
|
||
выглядит циклом перезапуска, и лечится возвратом бинаря вперёд. Версию читает
|
||
сам goose (`Provider.GetVersions`), а не собственный запрос: имя таблицы учёта и
|
||
правило «максимум = текущая версия» принадлежат ему, и рукописная копия
|
||
разошлась бы при обновлении зависимости — причём не отказом, а тем, что страж
|
||
перестал бы ловить.
|
||
|
||
Цена названа вслух, потому что она реальна: пока сервис не поднят, приём не
|
||
работает, а доставка, не попавшая в архив, в журнал не попадает вовсе — телефон
|
||
её не перешлёт. Выбор сделан так потому, что откат это действие оператора,
|
||
который в этот момент рядом и видит отказ немедленно, а дыры плотных метрик за
|
||
время простоя закроют широкий и глубокий проходы синхронизации. Не закроют
|
||
`stateOfMind`: у него доставки HAE единственный источник — это и есть цена
|
||
решения. Она меньше цены молчания: старый бинарь поверх новой схемы стартовал
|
||
бы успешно, незнакомые секции игнорировал и доставки за всё окно отката помечал
|
||
разобранными, а узнать об этом было бы неоткуда.
|
||
|
||
Открытие базы **только на чтение** (`reindex`, утилиты учёта) остаётся строгим:
|
||
там отказ даёт любое расхождение версий, включая базу старее бинаря — читать
|
||
колонки, которых ещё нет, нечем. База без журнала миграций отвергается сразу и
|
||
структурным вопросом к `sqlite_master`, а не через сам goose: тот при отсутствии
|
||
таблицы идёт её создавать, и на соединении «только чтение» это три секунды
|
||
повторов и ответ про права на файл вместо ответа про версию. Асимметрия только у
|
||
открытия с накатом.
|
||
|
||
**Понижение схемы не поддерживается: откат — только вперёд.** Подкоманды
|
||
миграции у бинаря нет, `goose` CLI в образ не кладётся, `-- +goose Down` в
|
||
миграциях существует для локальной разработки и на рабочей базе не исполнялся ни
|
||
разу. Значит после наката новой схемы возврат прежнего бинаря приёма не чинит —
|
||
чинит только выкатка вперёд. Это цена стража, названная целиком; чем её
|
||
смягчать, решает отдельная задача беклога.
|
||
|
||
## Открытые вопросы
|
||
|
||
- Механизм доставки образа и запуска на rivendell (compose руками / плейбук).
|
||
- **Предел размера ответа** — в точках или в оценке байт. Точки считать
|
||
проще, но у `heart_rate_variability` с `heartbeatSeries` точка на два
|
||
порядка тяжелее, чем у `step_count` (находка 39).
|
||
- **Хранить ли `heartbeatSeries` целиком.** 93% объёма HRV ради данных,
|
||
которых нет ни в одном из планируемых запросов.
|
||
- **Есть ли `stateOfMind`, симптомы и лекарства в родном экспорте.** От этого
|
||
зависит, применимо ли к ним устаревание нижнего слоя.
|
||
- **Как проверять покрытие экспортом** — до какой строгости. Непрерывности по
|
||
дням и сходимости сумм, вероятно, хватит, но порог не выбран.
|
||
- **Хранилище под аналитику.** Сейчас SQLite: часовые объекты дают ~260 тыс.
|
||
строк на слой в год независимо от плотности точек, а плоская таблица по
|
||
грубым слоям (`hour` ~36 тыс. строк в год, `minute` ~1.4 млн) делает
|
||
`GROUP BY` дешёвым без разжатия блобов. DuckDB рассматривался и отложен:
|
||
чистого Go-драйвера нет, любой требует cgo, что стоит нам
|
||
`CGO_ENABLED=0` и одного статического бинаря. Дверь при этом открыта —
|
||
DuckDB читает и parquet, и файл SQLite напрямую, так что выгрузка в parquet
|
||
остаётся отдельной командой на случай тяжёлой аналитики снаружи.
|