- каждая запись каталога задач получила тип вместо тега kind: и префикса заголовка; секция роадмапа «Разработка» стала «Сопровождением», порядок секций канонический - поправлены протухшие факты: нереализованные маршруты Read API, MCP и `healthlog import`, словарь слоёв в инварианте, семантика гейта по покрытию диффа, периметр перестал дублировать security.md - замер слияния переведён с находки 49 на находку 54, заполнены Purpose спек storage и parsing
246 lines
27 KiB
Markdown
246 lines
27 KiB
Markdown
# Схема базы
|
||
|
||
SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, файлы в
|
||
`internal/store/migrations`. Время в текстовых колонках всегда **UTC RFC 3339**
|
||
с секундной точностью: фиксированная ширина делает лексикографическую
|
||
сортировку `TEXT` совпадающей с хронологией.
|
||
|
||
**База производна.** Источник истины — сырой архив тел (`data/raw`); состояние
|
||
пересобирается свёрткой `import(экспорт Apple) + replay(доставки по received_at)`.
|
||
Поэтому терять базу неприятно, но не смертельно, а вот терять архив — смертельно.
|
||
|
||
```
|
||
┌────────────────────────────┐ ┌──────────────────────────────┐
|
||
│ delivery │ │ bucket │
|
||
│ ───────────────────────── │ │ ──────────────────────────── │
|
||
│ id TEXT PK │ │ metric TEXT ┐ │
|
||
│ received_at TEXT │ │ layer TEXT ├ PK │
|
||
│ automation_name TEXT │ ┄┄┄┄┄▶ │ hour_utc TEXT ┘ │
|
||
│ automation_id TEXT │ первая │ units TEXT │
|
||
│ aggregation TEXT │ доставка │ payload BLOB │
|
||
│ period TEXT │ часа │ content_hash TEXT │
|
||
│ session_id TEXT │ │ points INTEGER │
|
||
│ bytes INTEGER │ │ first_ts TEXT │
|
||
│ sha256 TEXT │ │ last_ts TEXT │
|
||
│ raw_path TEXT │ │ first_delivery_id TEXT │
|
||
│ parse_status TEXT │ │ sealed INTEGER │
|
||
│ points INTEGER │ │ created_at TEXT │
|
||
│ headers TEXT │ │ updated_at TEXT │
|
||
│ derived_layer TEXT │ └──────────────────────────────┘
|
||
│ uncovered_sections TEXT │
|
||
│ skipped_entities INTEGER? │
|
||
└────────────────────────────┘
|
||
┊ ┌──────────────────────────┐ ┌──────────────────────────┐
|
||
┊ │ workout │ │ record │
|
||
┊ доставка, │ ─────────────────────── │ │ ─────────────────────── │
|
||
└┄┄┄ чья версия ┄┄┄┄▶ │ id TEXT PK│ │ kind TEXT ┐ │
|
||
лежит сейчас │ name TEXT │ │ id TEXT ┘PK│
|
||
│ start_utc TEXT │ │ ts_utc TEXT │
|
||
│ end_utc TEXT │ │ tz_offset INTEGER│
|
||
│ tz_offset INTEGER│ │ payload BLOB │
|
||
│ duration_sec REAL? │ │ content_hash TEXT │
|
||
│ payload BLOB │ │ delivery_id TEXT │
|
||
│ content_hash TEXT │ │ delivery_received_at TEXT│
|
||
│ delivery_id TEXT │ │ created_at TEXT │
|
||
│ delivery_received_at TEXT│ │ updated_at TEXT │
|
||
│ created_at TEXT │ └──────────────────────────┘
|
||
│ updated_at TEXT │
|
||
└──────────────────────────┘
|
||
|
||
┌────────────────────────────┐
|
||
│ category_value │
|
||
│ ───────────────────────── │
|
||
│ metric TEXT ┐ │
|
||
│ field TEXT ├PK │
|
||
│ value TEXT ┘ │
|
||
│ code TEXT │
|
||
│ first_seen_utc TEXT │
|
||
│ first_delivery_id TEXT │
|
||
└────────────────────────────┘
|
||
```
|
||
|
||
Связь `bucket.first_delivery_id → delivery.id` **внешним ключом не объявлена**
|
||
намеренно: доставки подрезаются ретеншеном до следующего проверенного экспорта,
|
||
а объекты живут дольше. Ссылка — провенанс для разбора слияний, а не
|
||
целостность.
|
||
|
||
## `delivery` — учёт принятых пакетов
|
||
|
||
Одна строка на принятое тело. Само тело лежит в архиве по `raw_path`
|
||
(относительный путь внутри `data/raw`).
|
||
|
||
| Колонка | Смысл |
|
||
|---|---|
|
||
| `id` | ULID, он же имя файла в архиве |
|
||
| `received_at` | время приёма, UTC |
|
||
| `automation_name`, `automation_id` | какая автоматизация HAE прислала; `automation_id` нужен выводу слоя — по нему наследуется слой доставки без плотных метрик |
|
||
| `aggregation` | заголовок `automation-aggregation`. Режима **не означает**: значение `Default` наблюдалось у посекундного, минутного и часового режимов одновременно |
|
||
| `period` | заголовок периода (`Since Last Sync` и прочие) |
|
||
| `bytes`, `sha256` | размер и хеш тела; хеш пока только для учёта |
|
||
| `parse_status` | `pending` / `parsed` / `partial` / `failed`. Код ответа приёма от него **не зависит**: сохранили — значит приняли. `pending` означает «ЭТИМ разбором ещё не смотрели», а не «тела не касались»: миграция 00005 перевела сюда доставки, разобранные кодом, который частичного разбора не различал |
|
||
| `points` | сколько точек дал разбор |
|
||
| `headers` | все заголовки запроса JSON-объектом, кроме несущих секреты |
|
||
| `uncovered_sections` | секции тела, которых разбор не покрыл, JSON-массивом имён; пустой список — `[]`. Ответ на вопрос «что останется потерянным, если тело удалить»: для `stateOfMind` он необратим, в экспорте Apple секции нет. Ретеншен обязан спрашивать его прежде, чем срезать тело |
|
||
| `skipped_entities` | сколько сущностей с собственным `id` разбор пропустил (нет `id`, `id` длиннее предела, метка не разбирается, элемент не объект). Вторая половина ответа на «что потеряется, если тело удалить»: список непокрытых секций про пропущенную сущность молчит. **NULL означает «не измерялось»** и нулю не равен — так выглядят доставки, свёрнутые разбором, который пропусков не считал; читатель, принимающий по счётчику необратимое решение, обязан трактовать NULL как «не удалять» |
|
||
| `derived_layer` | слой, выведенный для этой доставки. Нужен не отчётности, а самому выводу: доставка без плотных метрик наследует последний надёжно выведенный слой той же автоматизации, и без хранения этой памяти первая такая доставка после перезапуска осталась бы без слоя |
|
||
|
||
Индексы: `delivery_received_at` (порядок журнала), `delivery_sha256` (учёт
|
||
повторов), `delivery_automation_layer` (поиск последнего слоя автоматизации),
|
||
`delivery_pending` (очередь свёртки).
|
||
|
||
`delivery_pending` **частичный** — только строки со статусом `pending`. Таблица
|
||
и есть очередь фоновой свёртки: воркер выбирает неразобранные доставки в
|
||
порядке журнала чаще, чем раз в минуту. В установившемся режиме в индексе
|
||
ноль-одна строка, тогда как полный индекс по `parse_status` хранил бы всю
|
||
историю ради выборки из одной.
|
||
|
||
## `bucket` — часовой объект точек
|
||
|
||
Единица хранения — час одной метрики в одном слое, а не отдельная точка.
|
||
|
||
| Колонка | Смысл |
|
||
|---|---|
|
||
| `metric` | имя метрики как прислал HAE. Исключение — `sleep_analysis_summary`: под именем `sleep_analysis` приезжают две несовместимые схемы, и они разводятся на разные имена |
|
||
| `layer` | `sample` / `raw` / `minute` / `hour` / `day`. Выводится из выравнивания меток |
|
||
| `hour_utc` | начало часа, UTC. Час берётся **по началу точки** |
|
||
| `units` | единицы метрики. Внутри точки их нет, они живут на уровне метрики |
|
||
| `payload` | точки часа: JSON-массив исходных байтов, gzip. Точки упорядочены по началу |
|
||
| `content_hash` | хеш канонической формы — **детектор изменений**, не ключ. Совпал — записи нет |
|
||
| `points` | сколько точек внутри |
|
||
| `first_ts`, `last_ts` | границы содержимого; каталогу разрезов, чтобы не разжимать блоб ради диапазона |
|
||
| `first_delivery_id` | доставка, создавшая объект |
|
||
| `sealed` | час, в который досчёт не ожидается. Правило перевода пока не определено |
|
||
|
||
Таблица `WITHOUT ROWID`: обращение всегда по полному первичному ключу, и
|
||
лишний уровень косвенности через rowid ни разу не нужен.
|
||
|
||
Индекс `bucket_catalog` (`metric, layer, hour_utc, first_ts, last_ts, points,
|
||
units`) — **покрывающий**, и это следствие той же формы таблицы: у `WITHOUT
|
||
ROWID` строка целиком, вместе со сжатым `payload`, живёт в дереве первичного
|
||
ключа, поэтому агрегат «какие слои есть у метрики и за какой период» без индекса
|
||
тащил бы страницы содержимого — сотни мегабайт чтения на запрос каталога при
|
||
260 тысячах объектов за год. По нему же идёт поиск часов, за которые у метрики
|
||
есть объекты сразу в двух слоях. Цена — около 60 байт на объект и одна вставка в
|
||
дерево на запись; платит её только настоящее изменение, потому что при совпавшем
|
||
хеше объект не переписывается вовсе.
|
||
|
||
**Идентичность точки внутри объекта** — координаты
|
||
`метрика + слой + начало + конец`, у точки-измерения конец равен началу.
|
||
`source` в ключ не входит: он нестабилен и переписывается задним числом. При
|
||
столкновении выигрывает более полная точка, а при равной полноте — стоящая
|
||
позже в журнале (внутри одной доставки — минимум канонической формы). Провенанса
|
||
у точки нет: «позже в журнале» выражено происхождением кандидата, и потому
|
||
порядок свёртки обязан равняться журнальному.
|
||
|
||
## `workout` и `record` — сущности с собственным `id`
|
||
|
||
Вторая единица хранения витрины. Часовой объект им не подходит: у них есть
|
||
естественный ключ, они редки (за двое суток потока — две тренировки и две
|
||
записи состояния разума при 44 и 52 доставленных копиях), и группировать их по
|
||
часам незачем.
|
||
|
||
Таблицы две, а не одна с колонкой рода: у тренировки есть заголовок, по
|
||
которому идёт выборка (имя, интервал, длительность), а у записи его нет. Общая
|
||
таблица либо теряла бы заголовок, либо держала колонки, пустые у пяти родов из
|
||
шести.
|
||
|
||
| Колонка | Смысл |
|
||
|---|---|
|
||
| `workout.id` | идентификатор из HealthKit. Приходит из тела и ограничен по длине разбором: уезжает и в ключ, и в записи лога |
|
||
| `record.kind` + `record.id` | ключ — **пара**. Собственный `id` наблюдался живьём только у `stateOfMind`, где он UUID; форма идентификатора остальных пяти секций не наблюдалась никем, и короткий несквозной `id` в двух разных секциях затёр бы одну запись другой молча |
|
||
| `kind` | верхнеуровневый ключ секции HAE **дословно** (`stateOfMind`, не `state_of_mind`): инвариант «форма Apple не транслируется» относится и к именам секций |
|
||
| `start_utc` / `end_utc` / `ts_utc` | UTC RFC 3339. Конец, которого нет или который не читается, равен началу: ключ — `id`, схлопывать координаты нечем, а истина остаётся в `payload` |
|
||
| `tz_offset` | смещение зоны **начала**. У `stateOfMind` всегда `0` — это значит «источник прислал UTC», а не «человек был в Гринвиче»: местной зоны у секции в потоке нет вовсе. Клиент, считающий по нему местные сутки, ошибётся |
|
||
| `duration_sec` | длительность тренировки в секундах, как прислал HAE. **`NULL` означает «источник не прислал»**: ноль — законная длительность. Не вычисляется из интервала — HAE шлёт 91.746 при интервале в 91 секунду |
|
||
| `payload` | сущность целиком исходными байтами, gzip: заголовок, маршрут, внутренние ряды и сводки. Маршрут — 95% веса тренировки, а такой JSON жмётся примерно в 25 раз. Внутрь SQL-функциями не заглянуть — та же плата, что у `bucket.payload` |
|
||
| `content_hash` | хеш канонической формы: детектор изменений, не ключ. Тренировка переприсылается каждой доставкой, пока не доедет маршрут (44 копии дают три различных содержимых) |
|
||
| `delivery_id`, `delivery_received_at` | провенанс: доставка, **чья версия лежит сейчас**, и её метка приёма. Не отчётность: по паре разрешается тай-брейк между версиями равной полноты |
|
||
|
||
Индексы: `workout_start_utc` («заголовки тренировок за период» — основной запрос
|
||
трекера), `record_kind_ts` («записи такого-то рода за период» — единственная
|
||
форма запроса к таблице).
|
||
|
||
**Ряд пульса внутри тренировки лежит в её `payload`, а не в объектах метрики
|
||
`heart_rate`.** Пульс приезжает дважды — в общем потоке и внутри тренировки; это
|
||
разные таблицы, и смешение задвоило бы ряд.
|
||
|
||
**Замена версии условна.** Приехавшая побеждает, если не теряет содержания
|
||
сохранённой (множество ключей с непустым значением плюс длины верхнеуровневых
|
||
массивов); при равных наборах выигрывает версия из более поздней доставки
|
||
журнала, а не свёрнутая последней. Подробности и обоснование — в
|
||
`architecture.md`, раздел «Тренировки и прочие секции».
|
||
|
||
## `category_value` — реестр категориальных значений
|
||
|
||
Какие перечислимые строки поток приносил и какой у них стабильный код
|
||
HealthKit. HAE отдаёт фазу сна как «БДГ», контекст пульса как «Сидячий образ
|
||
жизни», тип тренировки как «В помещении Ходьба» — строками локали телефона, а
|
||
родной экспорт Apple говорит кодами; без словаря источники не сходятся
|
||
(находка 37). Словарь фаз сна выведен сопоставлением потока с экспортом за тот
|
||
же период (находка 43).
|
||
|
||
| Колонка | Смысл |
|
||
|---|---|
|
||
| `metric` | имя метрики или секции, **то же**, которым адресуется единица хранения (`sleep_analysis_summary` после разделения схем, `workouts` у тренировок). Второе имя для того же понятия развело бы наблюдение и объект по разным ключам |
|
||
| `field` | имя поля внутри точки или сущности дословно как у HAE: `value`, `context`, `name` |
|
||
| `value` | строка **дословно**, как прислал HAE. Код приписывается рядом, а не подменяет её: инвариант «точки хранятся дословно» это и означает |
|
||
| `code` | канонический код HealthKit. Пустая строка — законное состояние: «словарь этой строки не знает», и перечень таких строк есть заявка на пополнение словаря. **Это кэш**: код производен от словаря в бинаре, а не от журнала, и потому в отпечаток витрины не входит. Строка, переставшая приезжать, держит код прежнего словаря до пересборки |
|
||
| `first_seen_utc`, `first_delivery_id` | провенанс **первой** встречи, минимум по журналу `(received_at, id)`. Минимум идемпотентен при повторной свёртке той же доставки; счётчик встреч не идемпотентен и потому не заводится вовсе. Отвечает на вопрос «когда сменился язык телефона», а язык доставки восстанавливается по `delivery.headers` |
|
||
|
||
Ключ — тройка без локали, и это решение, а не упущение. Локаль приезжает
|
||
заголовком `Accept-Language`, а заголовков в сыром архиве нет: они были
|
||
заголовками запроса, а не телом. Доставка, восстановленная из осиротевшего
|
||
тела, приходит без локали — ключ с локалью положил бы вторую строку на то же
|
||
значение, то есть состояние стало бы функцией от того, уцелела ли учётная
|
||
строка. Локаль при выводе кода сужает поиск по словарю; её отсутствие вывода не
|
||
отменяет, если строка однозначна.
|
||
|
||
Таблица `WITHOUT ROWID`: обращение всегда по полному первичному ключу, а строк
|
||
единицы — на живом потоке различных значений по всем трём полям около
|
||
одиннадцати. Индексов нет: чтение идёт целиком, в порядке ключа.
|
||
|
||
Границы разбора не дают доставке положить больше 64 различных значений и
|
||
значение длиннее 128 байт (измерено: ~11 значений, самое длинное 36 байт).
|
||
Слишком длинное **отбрасывается со счётчиком, а не обрезается** — обрезанная
|
||
строка неотличима от настоящей и стала бы самостоятельным ключом; сама точка
|
||
при этом хранится целиком.
|
||
|
||
Data-миграции у таблицы нет и быть не может: коды выводятся из тел, а тела
|
||
лежат в архиве. Реестр рабочей витрины наполняется по мере свёртки новых
|
||
доставок и целиком — пересборкой. Отсюда первое расхождение отпечатков после
|
||
выкатки: оно законно, и отчёт `reindex` называет его ожидаемым классом
|
||
«появилась единица хранения».
|
||
|
||
## Представление данных
|
||
|
||
- **Точки часового объекта лежат сжатым BLOB** (`gzip`) в колонке `payload`.
|
||
Чтение объекта распаковывает его **целиком**: частичного доступа к точке нет,
|
||
и любая правка — read-modify-write всей пачки. Отсюда цена широкой доставки:
|
||
63 МиБ на одной координате держат транзакцию 5.15 с, а тело 40 МиБ давало
|
||
768 МиБ пика кучи, пока канонизация шла внутри транзакции.
|
||
- Таблицы часовых объектов — `WITHOUT ROWID`: строка целиком, вместе со сжатым
|
||
`payload`, живёт в дереве первичного ключа. Поэтому агрегатные запросы идут
|
||
по покрывающему индексу `bucket_catalog`, а не по таблице.
|
||
- Тела доставок в базе не лежат вовсе — они в сыром архиве
|
||
(`<archive_dir>/raw/ГГГГ/ММ/ДД/<ulid>.json.gz`); в `delivery` только учёт.
|
||
|
||
## Настройки с числовым значением
|
||
|
||
Без них замер не превращается в находку: пик памяти — аномалия только рядом со
|
||
строкой «запись лежит сжатой и распаковывается целиком».
|
||
|
||
| Настройка | Значение | Где задана |
|
||
| --- | --- | --- |
|
||
| `journal_mode` | `WAL` | `internal/store/store.go`, строка соединения |
|
||
| `busy_timeout` | `5000` мс | там же; на устаревший снимок транзакции **не** действует |
|
||
| `foreign_keys` | `on` | там же |
|
||
| `journal_size_limit` | 64 МиБ | `internal/store/store.go`, `journalSizeLimit` |
|
||
| чекпойнт WAL по таймеру | 1 мин | `cmd/healthlog/checkpoint.go`, `checkpointInterval` |
|
||
| предел тела запроса | 64 МиБ (`ingest.max_body_mb`) | конфиг; **ретроактивен** — тем же пределом читаются тела из архива при пересборке |
|
||
| таймаут чтения запроса | 5 мин (`server.read_timeout`) | конфиг; щедро: экспорт истории по мобильной сети |
|
||
| таймаут отправки ответа | 30 с (`server.write_timeout`) | конфиг; маршрут приёма держит собственный бюджет |
|
||
| бюджет остановки | 30 с | `cmd/healthlog/serve.go`, `shutdownTimeout` |
|
||
| дедлайн свёртки одной доставки | 2 мин | `internal/replay/worker.go`, `foldTimeout`; обстоятельством не считается — не уложившаяся доставка уходит в `failed` |
|
||
| ретеншен сырого архива | до следующего проверенного экспорта (~2 ГБ за квартал) | правило, а не число; не реализован — задача `raw-archive-retention` |
|
||
| предела на одну сущность | **нет** | задача `entity-size-limits` |
|