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

27 KiB
Raw Permalink Blame History

Схема базы

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