Files
healthlog/docs/database.md
T
av 63bffe2865 Приём отвечает 200 до свёртки, свёртку ведёт фоновый воркер
- Очередью служит сама таблица: доставка ждёт свёртки в статусе `pending`,
  канал несёт только бит «есть работа». Переполнять нечего, падение процесса
  очередь не теряет, а подбор `pending` при старте — обычный проход воркера, а
  не отдельный код. Классификация исхода общая с пересборкой журнала.
- Исход разбора начал отражать доставку, а не обстоятельства: отмена и
  занятость базы статус не меняют (иначе конкуренция за базу выводила бы
  доставку из очереди навсегда), паника свёртки больше не валит процесс, а
  учёт доставки идёт через транзакцию с повторами.
- Длинный бюджет ответа выдан маршруту приёма, а не всему серверу:
  `write_timeout` в Go покрывает и чтение тела, и общий подъём снял бы защиту с
  остальных маршрутов.
2026-08-02 11:01:42 +03:00

8.8 KiB

Схема базы

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    │
└────────────────────────────┘

Связь 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 секции нет. Ретеншен обязан спрашивать его прежде, чем срезать тело
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 ни разу не нужен.

Идентичность точки внутри объекта — координаты метрика + слой + начало + конец, у точки-измерения конец равен началу. source в ключ не входит: он нестабилен и переписывается задним числом. При столкновении выигрывает более полная точка, а не последняя пришедшая.