Files
av 3d24248075 docs: документация приведена к канону av-dev-pm 4
- каждая запись каталога задач получила тип вместо тега kind: и префикса
  заголовка; секция роадмапа «Разработка» стала «Сопровождением», порядок
  секций канонический
- поправлены протухшие факты: нереализованные маршруты Read API, MCP и
  `healthlog import`, словарь слоёв в инварианте, семантика гейта по покрытию
  диффа, периметр перестал дублировать security.md
- замер слияния переведён с находки 49 на находку 54, заполнены Purpose спек
  storage и parsing
2026-08-05 19:09:35 +03:00

246 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Схема базы
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` |