- байтовый порядок канонических форм остался тай-брейком только внутри одной доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил меньшее значение, из-за чего step_count терял род и verify:archive был красным - правило перестало быть коммутативным осознанно, поэтому порядок свёртки приведён к журнальному: проход воркера прекращается на отложенной доставке, а свёртка вне порядка журнала пишет WARN - заведены счётчики PointsHeld и PointsErased — удержание полнотой и единственное направление, в котором правило теряет содержание
27 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 │
│ 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 ГБ за квартал) | правило, а не число; не реализован — задача raw-archive-retention |
| предела на одну сущность | нет | задача entity-size-limits |