- беклог и план переехали в docs/tasks (38 задач, 11 целей), слаги переименованы с транслита на английские, 85 ссылок поправлены - conventions.md разобран в docs/conventions/, local-research.md — в docs/research/, review-journal.md — в docs/review.md с разделом настройки конвейера; заведены security.md, adr/ и .pm.json - шаг docs.py check добавлен в task gate; поведение в architecture.md помечено девятью маркерами долга, database.md получил настройки с числовым значением
36 KiB
Context
Разбор покрывает одну секцию тела — metrics. Остальное перечисляется в
delivery.uncovered_sections, доставка получает partial, тело живёт в архиве.
Замер по 118 доставкам архива: metrics — 65 доставок, workouts — 27,
stateOfMind — 26; ни одна доставка не несла двух секций сразу.
Тренировка и состояние разума устроены иначе, чем метрика, и это не стилистика:
- у них есть собственный
id(UUID из HealthKit) — координатный ключметрика + слой + начало + конецим не нужен; - они редки: за двое суток потока — 2 разных тренировки и 2 разных записи состояния разума, при 44 и 52 доставленных копиях соответственно;
- у них нет слоя: подробности выгрузки у этих секций в интерфейсе HAE не бывает, есть только глубина окна;
- тренировка тяжёлая: маршрут — 95% её веса (190 КБ из 199,6 КБ у десятиминутной прогулки), и приезжает она повторно, пока маршрут не доедет.
Замер поведения при переприсылке (тот же архив, группировка по id):
тренировка A 26 копий 3 различных содержимых поля росли, не убывали
тренировка B 18 копий 1 содержимое маршрут с первой копии
stateOfMind 26 и 26 копий, по 1 содержимому каждая
Что именно менялось у тренировки A между версиями:
версия 0 → 1 +stepCadence, +stepCount, изменилось значение activeEnergy
версия 1 → 2 набор полей тот же, изменились totalEnergy и basalEnergy
То есть тренировка досчитывается задним числом ровно так же, как минутное ведро (находка 10), и при этом набор полей за весь корпус ни разу не уменьшился.
Goals / Non-Goals
Goals:
- Тренировка лежит в витрине целиком, вместе с маршрутом и внутренними рядами, дословно и без интерпретации.
- Состояние разума лежит записями — секция, которой нет в экспорте Apple, больше не зависит от того, что тело не удалили.
- Свёртка остаётся детерминированной:
reindexдаёт то же состояние, что живой приём, и это проверяется отпечатком, а не «числом строк». - Правило «при столкновении выигрывает более полная версия» продолжает
действовать и для сущности с собственным
id.
Non-Goals:
- Отдача наружу. Read API в проекте нет вовсе; форма конверта, выбор слоя и
предел размера ответа проектируются задачей
read-api-points. Два эндпоинта, введённые раньше конверта, задали бы контракт мимоходом. - Секции, которых поток не приносил (
ecg,symptoms,cycleTracking,medications,heartRateNotifications). Модель под них закладывается — таблицаrecordключуется родом секции, — но разбор не пишется вслепую: их формы никто не видел, а задачаunseen-sections-checkсуществует ровно про момент, когда они появятся. - Разворачивание маршрута в таблицу точек — отдельная идея беклога, у неё нет клиента.
- Словарь категориальных значений (
nameтренировки — «В помещении Ходьба», машинная калька) — отдельная задача; здесь строка хранится дословно.
Decisions
1. Две таблицы, а не одна с колонкой рода
workout и record разведены, как и записано в docs/architecture.md.
Общая таблица entity(kind, id, …) выглядит экономнее и хуже по существу: у
тренировки есть заголовок, который нужен запросом «что было за период» —
name, start, end, duration, — а у записи состояния разума его нет.
Общая таблица либо теряет заголовок (тогда список тренировок требует разжатия
каждого блоба), либо заводит колонки, пустые у пяти родов из шести.
Отвергнуто и обратное — таблица на каждый род секции: шесть почти одинаковых
таблиц, и каждая новая секция требует миграции. record ключуется родом, и
новая секция добавляется одной строкой в множество покрытых имён.
2. Ключ record — (kind, id), а не один id
Отклонение от схемы, набросанной в architecture.md (record(id PK, kind, …)),
и оно намеренное. У stateOfMind id — настоящий UUID HealthKit, но остальные
пять секций живьём не видели никто: форма их идентификатора неизвестна, и
короткий несквозной id в двух разных секциях молча затёр бы одну запись
другой. Пара стоит ноль (запросы к record всегда идут с родом: GET /records/{kind}) и снимает целый класс.
Ключ workout — id: род у него один.
3. Сущность заменяется целиком; побеждает не последняя, а не теряющая полей
Центральное решение задачи. Тренировка «перезаписывается» (беклог,
architecture.md), но инвариант проекта гласит «при столкновении выигрывает
более полная точка, а не последняя пришедшая». Развилку решает замер выше.
Правило:
1. каноническая форма совпала с сохранённой → записи нет (хеш-детектор)
2. приехавшая несёт всё, что сохранённая, и
сверх того → приехавшая замещает целиком
3. приехавшая теряет содержание сохранённой → остаётся сохранённая,
счётчик + WARN
4. содержание сравнимо (наборы равны) → версия из БОЛЕЕ ПОЗДНЕЙ
доставки журнала
5. наборы несравнимы → остаётся сохранённая,
счётчик + WARN
«Теряет содержание» считается по множеству ключей, а не по canon.Relate.
Это единственная деталь, где реализация не может переиспользовать правило точек
как есть, и цена ошибки здесь — маршрут. Проверено выполненной командой на копии
пакета canon:
сохранённая vs обеднённая, значения общих полей те же : superset
сохранённая vs обеднённая, значения общих полей иные : equal
сохранённая vs усечённый маршрут (2 точки → 1) : equal
canon.Fields.Relate гасит отношение включения до equal, когда значения
общих содержательных ключей разошлись, — и это верно для точки (надмножество
имён при других значениях означает другое измерение), но неверно для сущности:
замер выше говорит, что между версиями тренировки значения меняются всегда.
То есть настоящая обеднённая версия пришла бы с изменёнными значениями,
получила бы equal и заместила бы сохранённую целиком, а тест на наивной
фикстуре (значения не тронуты) остался бы зелёным.
Поэтому в canon заводится вторая, явная операция — сравнение множеств
содержательных ключей без условия о совпадении значений, поверх уже
существующего внутреннего relateKeys. Именно в canon, а не в store:
пакет заведён ради единственной реализации сравнения, и вторая копия разошлась
бы с первой молча.
Полнота меряется верхним уровнем ключей и длиной верхнеуровневых массивов.
Второе добавлено намеренно: усечённый маршрут (3 точки вместо 593) ключа не
теряет, поэтому одних множеств мало, а маршрут — 95% веса тренировки. Досчёт
ряды удлиняет, а не укорачивает, так что укорачивание — законный сигнал
«приехало меньше». Предел правила назван вслух: сокращение внутри элемента
ряда (точка маршрута потеряла altitude) не ловится ничем.
Тай-брейк при равных наборах — позиция доставки в журнале, а не порядок
свёртки. У точки при равной полноте исход решает порядок канонических форм
(canon.Less) — тай-брейк, который намеренно не выбран, пока не измерен род
агрегации. Приложи его к тренировке — и на наших же данных версия 1 → 2 (набор
полей тот же, досчитаны totalEnergy и basalEnergy) осталась бы на
произвольной из двух навсегда: тренировка замерла бы с недосчитанной
энергией. Причина расхождения содержательная: у точки на одной координате
законно встречаются два разных измерения (разные устройства,
пересэмплирование), и предпочитать позднее нет оснований; у сущности id —
идентичность одного объекта HealthKit, и вторая версия есть тот же объект,
пересчитанный источником.
Отвергнуто и напрашивавшееся «побеждает приехавшая»: приехавшая — это функция
порядка свёртки, а он не равен порядку журнала. Спека приёма говорит прямо,
что воркер сворачивает в порядке (received_at, id) только среди видимых
ему доставок, а абсолютного порядка при конкурентных приёмах не обещает
(docs/architecture.md, «Предел порядка назван вслух»; открытый блокер
journal-order-on-ingest.md). Доставка с более ранней меткой, свёрнутая
позже, вернула бы витрину к недосчитанной версии — и reindex разошёлся бы с
живым приёмом молча, в содержимом тренировки. Поэтому сущность несёт
провенанс — delivery_id и received_at своей доставки, — а тай-брейк
сравнивает пару (received_at, id). Тогда исход при равных наборах зависит
только от журнала, а не от того, кто раньше добрался до базы.
Провенанс нужен и сам по себе: у часового объекта он обязателен («провенанс для
разбора слияний»), а WARN об удержанной обеднённой версии без него не связать
с телом в архиве.
Две версии с одинаковым ключом внутри одной доставки (позиции равны) разрешаются минимумом канонической формы: порядок элементов в JSON-массиве нестабилен, и опираться на него нельзя.
Почему не голый upsert по id (как делает сервер HealthyApps поверх
MongoDB и как просилось из формулировки «перезаписывается»): единственный
сценарий, ради которого тренировка приезжает повторно, — доезжающий маршрут,
то есть рост. Обратное — приезд версии без маршрута — за 44 доставленные копии
не случилось ни разу, но стоит 95% содержимого тренировки, а восстановление
требует пересборки всего журнала. Условие пункта 3 стоит одного сравнения
множеств и делает событие наблюдаемым вместо необратимого.
Несравнимые наборы (приехавшая принесла новые ключи и потеряла старые) в пункте 5 разрешаются в пользу сохранённой: поля не объединяются, объединение отвергнуто там же, где для точек, — на живом потоке событие не наступало ни разу, и вместо реализации заведено наблюдение.
Остаточный предел назван вслух. Слияние попарное — сохранённая против
приехавшей, — поэтому при несравнимых наборах (пункт 5) исход зависит от порядка
проигрывания. Тот же предел есть у часового объекта: в объекте лежит победитель
прошлых слияний, а не все кандидаты истории. Пункты 2–4 от порядка свёртки не
зависят, а пункт 5 сопровождается счётчиком и WARN, поэтому событие не будет
молчаливым.
4. Свёртка доставки остаётся одной транзакцией
MergePoints превращается в Merge(ctx, Incoming{Points, Workouts, Records}, deliveryID): точки, тренировки и записи одной доставки пишутся одной
транзакцией. Спека хранения требует этого прямо («Доставка SHALL сворачиваться
одной транзакцией»), и требование не про точки, а про доставку: частичное
состояние ломает инвариант «состояние пересобираемо».
Наблюдение «ни одна доставка не несла двух секций сразу» (находка 50) собрано за двое суток и основанием для второй транзакции не является.
Имя MergePoints уходит: метод перестал сливать одни точки, а два метода с
двумя транзакциями были бы вторым способом делать то же самое.
5. payload — сжатый блоб, как у часового объекта
Дословные байты сущности, gzip. Тот же приём и по той же причине, что у
bucket: маршрут — 95% веса тренировки, JSON такого рода жмётся примерно в
25 раз, а прогулка в час даёт порядка мегабайта. Цена названа там же и здесь та
же: внутрь payload не заглянуть SQL-функциями. Для хранилища, которое отдаёт
тренировку целиком, это не потеря; заголовок, по которому идёт выборка, лежит
колонками.
Отвергнуто хранение текстом (как было набросано в architecture.md, payload JSON): второе кодирование для той же по природе величины стоило бы дороже
любой выгоды от json_extract, а объём — сотни мегабайт в год против десятков.
6. Заголовок тренировки — ровно то, по чему идёт выборка
name, start_utc, end_utc, tz_offset, duration_sec. Больше ничего:
любая следующая колонка — это решение за Apple о том, что в тренировке главное
(находка 15: сводки дублируют ряды, distance — это сумма
walkingAndRunningDistance).
duration берётся из тела, а не считается как end - start: HAE шлёт
91.746 секунды при интервале в 91 секунду, и вычисленное значение молча
разошлось бы с присланным. Отсутствует или не число — колонка NULL, а не
ноль: ноль — законная длительность, и потребитель, просуммировавший столбец, не
отличил бы «источник не прислал» от «измерено ноль». Тело в payload дословно в
любом случае.
end нечитаем или отсутствует — end_utc равен start_utc. У точки
вырождение интервала в мгновение запрещено, потому что схлопывает координату; у
сущности ключ — id, схлопывать нечего, а истина остаётся в payload. Офсет
берётся из start: колонка одна, а пробежка через смену зоны дала бы два
разных.
Метка записи — start, при его отсутствии date. end в заголовок не идёт:
у рода daily_mood он может отстоять от начала на сутки, и вторая колонка
понадобится вместе с запросом, которого пока нет.
name локализован («В помещении Ходьба»); хранится дословно, стабильный код
припишет задача словаря категориальных значений.
Значение kind у записи — верхнеуровневый ключ секции HAE дословно
(stateOfMind, не state_of_mind): инвариант «форма Apple не транслируется»
относится и к именам секций, а переименование после мерджа стоило бы миграции
данных.
7. Метка времени: у сущности оба известных формата, у точки — один
parseEntityTime пробует формат HAE (2026-07-31 21:03:51 +0300), затем
RFC 3339 (2026-07-31T18:03:51Z). Форматы измерены (находка 16: у тренировок
первый, у stateOfMind второй) и не пересекаются.
Отвергнуто приписывание формата секции: оно точнее описывает сегодняшний день и
ломается молча в тот, когда HAE выровняет секции между собой — а он к этому идёт
(stateOfMind уже шлёт честные коды HealthKit там, где старые секции шлют
переводы, находка 37). Цена терпимости нулевая: неоднозначности между двумя
формами нет.
Точка остаётся строгой, и это не забывчивость. У точки по метке выводится
слой, причём по метке местной: метка в UTC объявила бы часовую выгрузку
минутной, и минутный слой сложился бы с часовым (находка 35 — ровно такое
удвоение уже наблюдалось). Терпимый парсер там означал бы тихую порчу разреза;
строгий отдаёт непонятую метку в счётчик пропусков и WARN, а тело остаётся в
архиве. У сущности слоя нет, и терять на строгости нечего — асимметрия
намеренная.
Следствие, которое надо назвать вслух: у stateOfMind tz_offset всегда 0,
потому что HAE прислал UTC, а не потому, что человек был в Гринвиче. Местная
зона этой секции в потоке отсутствует.
8. Отпечаток витрины покрывает сущности, и снимается одним снимком
Store.Fingerprint — единственный оракул сходимости reindex и task verify:archive. Оставить его отпечатком одних часовых объектов значило бы
получить «состояние сошлось» при разъехавшихся тренировках — то есть сломать
проверку молча, ровно тем изменением, которое добавляет данные.
Три раздела читаются одной read-only транзакцией. Сегодня отпечаток — один
SELECT, то есть один снимок; три запроса подряд вне транзакции в режиме WAL
дают три снимка, а рабочий отпечаток снимается под живым приёмом. Свёртка,
закоммитившаяся между запросами, дала бы смесь «объекты до» и «тренировки
после», то есть ложное «разошлись» у единственного оракула. Прецедент в
проекте есть — Store.Bucket уже читает в BeginTx(ReadOnly).
Строки разделов идут с константным тегом впереди (b|, w|, r|): без него
строка одного раздела может совпасть со строкой другого — та же причина, по
которой поля переменной длины уже идут с длиной впереди.
Отчёт reindex расширяется вместе с отпечатком: счётчики тренировок и записей
«было и стало» рядом с числом объектов, и «покрыта новая секция» в перечне
ожидаемых классов расхождения. Иначе первый же прогон после мерджа даст
гарантированное расхождение отпечатков при неизменившемся числе объектов — и
оракул выродится в шум ровно тогда, когда по нему принимается необратимое
решение о подмене базы.
9. Покрытыми становятся ровно две секции
covered() — множество из трёх имён: metrics, workouts, stateOfMind.
Прочие секции с собственным id остаются в списке непокрытых, доставка с ними
остаётся partial, тело — в архиве. Это честно: формы этих секций никто не
видел, а «полнота покрытия HealthKit ради полноты» целью проекта не является
(паспорт).
Следствие, которое надо назвать вслух и передать дальше: доставка из одного
stateOfMind теперь получает parsed с пустым списком непокрытых, то есть
становится неотличимой от доставки из метрик — а метрики восстановимы из
экспорта Apple, состояние разума нет (находка 46). До этой задачи защита
работала побочным эффектом непокрытости. Ретеншена в проекте нет, поэтому здесь
ничего не ломается сегодня; но предусловие, которое задача
raw-archive-retention считала снятым, снова открыто, и это записывается в
её файл тем же изменением.
9а. Отказ разбора остаётся «всё или ничего» — теперь и для сущностей
Действующее требование сформулировано через точки, потому что другого результата у разбора не было. Три ветки надо назвать явно, иначе каждая решается реализацией молча:
- Тело оборвано после уже разобранной секции. Разбор отдаёт ошибку и ни точек, ни сущностей: иначе часть данных легла бы в витрину под статусом, по которому доставку никто не подберёт.
- Слой метрик не выводится, а в теле есть сущности. Доставка целиком уходит
в
failed, сущности не пишутся. Соблазн «сущностям слой не нужен, запишем их» ломает то же «всё или ничего»: доставка получила быfailedпри частично записанной витрине, и повторная свёртка перестала бы быть no-op. Тело остаётся в архиве, доставку вернёт пересборка. Цена названа: если такая доставка когда-нибудь принесётstateOfMind, его записи доедут не сразу, а ретеншенfailed-тела трогать не вправе. - Повтор ключа покрытой секции в одном
data. Секции объединяются, как уже задано дляmetrics. Заодно чинится существующий дефект уровнем выше:decodeEnvelopeпри повторе самого членаdataрезультат второго члена присваивает, а не добавляет, и имена первого глушатся общимseen— тело с двумяdataдоезжает доparsedс молча потерянной секцией.
9б. Пределы на чужие строки
id приходит из тела и ничем не ограничен, а уезжает и в первичный ключ, и в
записи лога. Предел — 128 байт (UUID HealthKit — 36); сущность с более длинным
id пропускается тем же счётчиком, что и сущность без id. Правило то же, что
уже действует для имён непокрытых секций, и оно снимает класс, а не случай.
10. Миграция пересворачивает то, что стало покрытым
Спека хранения уже требует: «Задача, которая начинает разбирать секцию, тем же
изменением SHALL переводить partial-строки с этим ключом в pending».
Миграция 00007 переводит в pending доставки, у которых в
uncovered_sections встречается workouts или stateOfMind. Дальше их
подберёт обычный проход фонового воркера — отдельного кода для этого не
существует.
Перевод точечный, а не «все partial»: список — снимок покрытия, и доставка с
непокрытой ecg пересворачивать нечего.
Risks / Trade-offs
- Приехала версия без маршрута, а поля при этом переименовались → правило
пункта 5 удержит сохранённую версию навсегда, и новых полей витрина не
увидит. → Счётчик и
WARNсidсущности; тело в архиве,reindexприменит исправленное правило. Событие не наблюдалось ни разу. - Сокращение внутри элемента ряда правилом не ловится. Длина
верхнеуровневых массивов сравнивается, а точка маршрута, потерявшая
altitude, — нет. → Названо вслух; ловится только сверкой с телом в архиве. - Маршрут удлиняет транзакцию свёртки. Верхняя граница задаётся не примером,
а пределом тела приёма: 64 МиБ распакованного тела из одних тренировок дают
десятки мегабайт содержимого в одной транзакции, а задача беклога «Цена
слияния на широкой доставке» уже описывает, как 63 МБ на одной координате
держат транзакцию дольше
busy_timeout. → Хеш-детектор снимает 41 запись из 44 на нашем корпусе, а сравнение начинается с узкогоSELECT content_hash, без чтения и разжатия блоба. Отдельной задачи не заводим: случай выражается той же беклоговой задачей, что и точки. - Канонизация маршрута разворачивает его в дерево
any.canon.Formматериализует значение целиком — та самая форма, от которой отказался разбор тела (197 МиБ кучи против 54 МиБ на теле 42 МиБ). → Хеш приехавшей сущности считается один раз на доставку, до входа в транзакцию, а не на каждой из пяти попыток повтора при занятости базы. importродного экспорта Apple не дастidтренировки. Вexport.xmlэлементWorkoutидентификатора не несёт —dogsheep/healthkit-to-sqliteпоэтому адресует тренировку хешем содержимого (hash_id="id"в sqlite-utils). Значит импорт снапшота задвоит тренировки, приехавшие от HAE, — ровно та же дыра, что у точек, где её закрыли ключомstart + end. → Предел назван здесь и заводится задачей беклога; сегодня импорта нет, и решать это до его формы значило бы угадывать.tz_offsetу записей состояния разума всегда ноль — не потеря наша, а форма источника. → Названо вdocs/database.md, чтобы клиент не считал по нему местные сутки.- Фикстуры собираются из живого архива. Тренировка несёт координаты
маршрута, запись состояния разума — эмоциональные метки; и то и другое
чувствительнее токенов. → Скрипт
tmp/research/fixtures.pyрасширяется: вычищаются числа (включая широту и долготу), UUID, метки RFC 3339 и словарные значенияstateOfMind; сохраняются форма литерала, структура и порядок ключей. Проверка «в индексе нет данных о здоровье» остаётся за гейтом.
Migration Plan
Миграция 00007_workout_record.sql:
CREATE TABLE workoutиCREATE TABLE recordс индексами по времени.UPDATE delivery SET parse_status = 'pending'для строк, чейuncovered_sectionsсодержитworkoutsилиstateOfMind.
Откат (Down) снимает таблицы; восстановление содержимого — обычная пересборка
из архива, витрина производна по построению. Строки, переведённые в pending,
Down обратно не возвращает: какими они были, восстановить неоткуда, а
pending консервативен — ретеншен его не трогает.
Живой сервис миграцию переживает: новые таблицы никого не блокируют, UPDATE
идёт по 118 строкам.