хранилище описано как свёртка по журналу событий
- экспорт Apple это снапшот всей истории, доставки после его даты — события поверх; состояние пересобирается как import(экспорт) + replay(доставки) - отсюда ретеншен архива меняется с произвольных 14 дней на «до следующего проверенного экспорта» (~2 ГБ за квартал, измерено), а свёртка обязана быть детерминированной — воспроизведение строго по received_at - названы границы модели: stateOfMind в экспорт не попадает вовсе, а верхние слои за периоды с удалёнными доставками не воскресают и досчитываться не должны — каталог обязан говорить это честно
This commit is contained in:
@@ -34,8 +34,9 @@ description: Конвейер ревью изменений healthlog — дет
|
||||
Инварианты, нарушение которых — по умолчанию `critical` (подробно —
|
||||
`CLAUDE.md`, `docs/architecture.md`):
|
||||
|
||||
- **Точка хранится дословно.** Всё, что теряет поле внутри точки, необратимо:
|
||||
сырой архив живёт 14 дней, дальше истина — сами точки.
|
||||
- **Точка хранится дословно.** Хранилище — свёртка по журналу
|
||||
(`import(экспорт) + replay(доставки)`), поэтому разобранное пересобираемо, а
|
||||
вот не принятое — нет: доставка мимо архива теряется навсегда.
|
||||
- **Идентичность по координатам** (`метрика + слой + метка`). `source` в ключ
|
||||
не входит. Неверное правило слияния портит историю молча — заметить это
|
||||
можно только сверкой с родным экспортом Apple, то есть месяцами позже.
|
||||
|
||||
@@ -24,13 +24,15 @@ healthlog — хранилище данных о здоровье, у котор
|
||||
(`task up` / `task restart`), данные в `./data`. Перезапуск на пару секунд
|
||||
безопасен — дыру закроют средний и глубокий проходы синхронизации; а вот
|
||||
сломанный приём, оставленный работать, теряет данные необратимо.
|
||||
- **Потерянная точка не восстанавливается.** Сырой архив живёт 14 дней. Любая
|
||||
правка разбора, слияния или вывода слоя — это `deep`-профиль ревью, без
|
||||
исключений.
|
||||
- **Потерянная доставка не восстанавливается.** Тело, не попавшее в архив, в
|
||||
журнал не попадает вовсе: телефон его не перешлёт. Разобранное же всегда
|
||||
пересобираемо свёрткой, поэтому цена ошибки разбора и цена ошибки приёма
|
||||
различаются на порядок. Любая правка разбора, слияния или вывода слоя — это
|
||||
`deep`-профиль ревью, без исключений.
|
||||
- **Данные чувствительны.** Ничего из `./data` не попадает ни в git, ни в
|
||||
логи выше `DEBUG`, ни в вывод агента. Гейт проверяет первое механически
|
||||
(`no-health-data`), остальное — на тебе.
|
||||
- **Разведка уже проведена.** `docs/local-research.md` — 41 находка на живом
|
||||
- **Разведка уже проведена.** `docs/local-research.md` — 46 находок на живом
|
||||
потоке, половина расходится с документацией HAE. Проверь там, прежде чем
|
||||
строить догадку о формате: скорее всего вопрос уже закрыт измерением.
|
||||
|
||||
|
||||
@@ -24,9 +24,15 @@ Module path — `git.vakhrushev.me/av/healthlog`.
|
||||
## Инварианты
|
||||
|
||||
- **Точки хранятся дословно.** Часовой объект держит точки ровно в том виде,
|
||||
в каком их прислал HAE. На этом инварианте держится всё остальное: сырой
|
||||
архив живёт лишь 14 дней, дальше истина — сами объекты. Начнём что-то
|
||||
отбрасывать внутри точки — срок хранения архива станет сроком жизни данных.
|
||||
в каком их прислал HAE. Начнём что-то отбрасывать внутри точки — потеряем
|
||||
безвозвратно.
|
||||
- **Хранилище — свёртка по журналу.** Экспорт Apple это снапшот всей истории,
|
||||
доставки HAE после его даты — события поверх. Состояние всегда пересобираемо:
|
||||
`import(экспорт) + replay(доставки по received_at)`. Поэтому сырой архив
|
||||
живёт до следующего проверенного экспорта (~2 ГБ за квартал), а свёртка
|
||||
обязана быть детерминированной. Что не восстанавливается — `stateOfMind`
|
||||
(его в экспорте нет) и верхние слои за периоды с удалёнными доставками;
|
||||
каталог обязан говорить об этом честно, а не досчитывать молча.
|
||||
- **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор:
|
||||
битый JSON — 400, непонятое содержимое — 200.
|
||||
- **Ничего не теряем молча.** Идентичность — координаты
|
||||
@@ -82,7 +88,8 @@ Module path — `git.vakhrushev.me/av/healthlog`.
|
||||
запускаются.
|
||||
|
||||
**Поток не останавливается.** Телефон шлёт непрерывно и молча. Сломанный приём,
|
||||
оставленный работать, теряет данные необратимо — сырой архив живёт 14 дней.
|
||||
оставленный работать, теряет данные необратимо: доставка, не попавшая в
|
||||
архив, в журнал не попадает вовсе — телефон её не перешлёт.
|
||||
Ничего из `./data` не попадает ни в git, ни в логи выше `DEBUG`, ни в вывод
|
||||
агента.
|
||||
|
||||
|
||||
@@ -34,22 +34,22 @@ healthlog делает это один раз. Телефон шлёт данн
|
||||
## Как устроено
|
||||
|
||||
```
|
||||
iPhone ──HTTPS POST──► healthlog ──► сырой архив (.json.gz, 14 дней)
|
||||
экспорт Apple ────────┐ снапшот всей истории, раз в 2–3 месяца
|
||||
▼
|
||||
iPhone ──HTTPS POST──► healthlog ──► журнал доставок (.json.gz)
|
||||
│ │
|
||||
│ └── страховка разбора, не склад
|
||||
│ └── события поверх снапшота
|
||||
▼
|
||||
SQLite ──┬──► HTTP read API ──► мои приложения
|
||||
(точки по │
|
||||
слоям) └──► MCP ───────────► агенты
|
||||
▲
|
||||
экспорт Apple ──────────────┘ нижний слой, раз в 2–3 месяца
|
||||
(свёртка по │
|
||||
журналу) └──► MCP ───────────► агенты
|
||||
```
|
||||
|
||||
Приём сначала кладёт тело запроса на диск как есть и только потом разбирает.
|
||||
Значит, ошибка в нашем разборе не может привести к потере данных: хранилище
|
||||
пересобирается из архива командой `healthlog reindex`. Архив при этом
|
||||
недолговечен — дальше истина в самих точках, и потому точки хранятся
|
||||
дословно.
|
||||
Значит, ошибка в разборе не теряет данные: состояние всегда пересобирается
|
||||
свёрткой `import(экспорт) + replay(доставки)`. Отсюда и главный инвариант —
|
||||
точки хранятся дословно: журнал, из которого что-то выброшено, перестаёт быть
|
||||
журналом.
|
||||
|
||||
Подробности — [docs/architecture.md](docs/architecture.md).
|
||||
|
||||
@@ -59,7 +59,7 @@ iPhone ──HTTPS POST──► healthlog ──► сырой архив (.jso
|
||||
сырой архив. Разбора, хранилища и read API ещё нет — план в
|
||||
[docs/plan.md](docs/plan.md).
|
||||
|
||||
Разведка формата закончена: 41 находка на живом потоке, половина расходится с
|
||||
Разведка формата закончена: 46 находок на живом потоке, половина расходится с
|
||||
документацией Health Auto Export — [docs/local-research.md](docs/local-research.md).
|
||||
|
||||
## Команды
|
||||
|
||||
+60
-12
@@ -15,11 +15,12 @@ healthlog принимает выгрузки Apple Health из приложен
|
||||
в каком их прислал HAE — без переименований, пересчётов и отбрасывания
|
||||
незнакомых полей. Поэтому хранилище само по себе является полной копией
|
||||
данных, а не производной выжимкой.
|
||||
- **Сырой архив — страховка разбора, а не вечный склад.** Тело запроса ложится
|
||||
на диск до разбора и живёт ограниченный срок (по умолчанию две недели):
|
||||
этого хватает, чтобы пережить ошибку в нашем разборе и пересобрать
|
||||
хранилище (`healthlog reindex`). Дальше источником истины остаются часовые
|
||||
объекты — прямое следствие пункта выше, см. «Хранилище».
|
||||
- **Хранилище — свёртка по журналу, а не единственная копия.** Экспорт Apple
|
||||
это снапшот всей истории, доставки HAE после его даты — события поверх
|
||||
снапшота. Состояние всегда пересобираемо: `import(экспорт) + replay(доставки)`.
|
||||
Отсюда срок жизни сырого архива — до следующего проверенного экспорта, а не
|
||||
произвольные две недели. Исключение названо вслух: `stateOfMind` в экспорт не
|
||||
попадает, см. «Хранилище».
|
||||
- **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор
|
||||
(см. «Приём»).
|
||||
- **Ничего не теряем молча.** Идентичность — устойчивые координаты
|
||||
@@ -236,16 +237,63 @@ HRV); у накопительных — только `date`. Поэтому то
|
||||
|
||||
## Хранилище
|
||||
|
||||
### Сырой архив — короткая страховка
|
||||
### Сырой архив и восстановление состояния
|
||||
|
||||
`raw/ГГГГ/ММ/ДД/<ulid>.json.gz` — тело запроса как пришло, не редактируется.
|
||||
Живёт **ограниченный срок** (`storage.raw_retention`, по умолчанию 14 дней),
|
||||
после чего удаляется.
|
||||
|
||||
Смысл срока: архив нужен, чтобы пережить ошибку в **нашем** разборе и
|
||||
пересобрать хранилище (`healthlog reindex`). Двух недель на это заведомо
|
||||
хватает. Вечно хранить его незачем — часовые объекты держат те же точки
|
||||
дословно, так что архив дублировал бы данные, а не страховал их.
|
||||
Два источника вместе образуют **полный журнал событий**, а хранилище —
|
||||
свёртку по нему:
|
||||
|
||||
```
|
||||
состояние = import(последний проверенный экспорт) ← снапшот всей истории
|
||||
+ replay(доставки после его даты) ← хвост событий
|
||||
```
|
||||
|
||||
Экспорт Apple — не просто «источник истины для нижнего слоя», а снапшот: он
|
||||
содержит всю историю целиком (3.6 млн записей с 2019 года). Доставки HAE после
|
||||
его даты — события поверх снапшота. Значит любое повреждение хранилища,
|
||||
включая ошибку в нашем разборе любой давности, лечится пересборкой, а не
|
||||
восстановлением из бекапа.
|
||||
|
||||
Отсюда три следствия, каждое из которых меняет реализацию.
|
||||
|
||||
**Срок жизни архива определяется циклом экспорта, а не календарём.** Прежние
|
||||
14 дней были произвольным числом. Правильное правило: доставки хранятся **до
|
||||
следующего проверенного экспорта**, иначе в журнале появится дыра между концом
|
||||
ретеншена и датой снапшота. Цена измерена: поток даёт ~23 МБ архива в сутки,
|
||||
то есть ~2 ГБ за квартал между экспортами. Это дёшево за возможность
|
||||
пересобрать что угодно.
|
||||
|
||||
**Свёртка обязана быть детерминированной.** Проигрывание должно давать то же
|
||||
состояние, что и приём в реальном времени. Слияние «выигрывает более полная
|
||||
точка» коммутативно и порядка не требует; но когда две одинаково полные точки
|
||||
несут разные значения, исход решает порядок — поэтому воспроизведение идёт
|
||||
строго по `received_at`, а не по порядку файлов в каталоге.
|
||||
|
||||
**`reindex` и `import` — одна операция, а не две.** Восстановление это импорт
|
||||
снапшота плюс проигрывание хвоста; отдельной «пересборки из архива» не
|
||||
существует, она просто вырожденный случай с пустым снапшотом.
|
||||
|
||||
#### Что не восстанавливается, и это сказано вслух
|
||||
|
||||
Модель почти полна, но не полностью — умолчать об этом опаснее, чем признать.
|
||||
|
||||
**`stateOfMind` в экспорте отсутствует вовсе.** Проверено на свежем экспорте:
|
||||
ни одного типа со словом `StateOfMind` (есть только `MindfulSession` — это
|
||||
минуты осознанности, другое). Состояние разума живёт **только** в доставках
|
||||
HAE. Значит для него доставки не хвост журнала, а единственный источник: либо
|
||||
они не удаляются никогда, либо его история держится на самих сохранённых
|
||||
записях и восстановлению не подлежит.
|
||||
|
||||
**Верхние слои за периоды с удалёнными доставками.** После проигрывания
|
||||
снапшота у старого периода будет только слой `sample`; `minute` и `hour` за
|
||||
него не воскреснут. Посчитать их вниз из `sample` технически можно — и нельзя
|
||||
по инварианту: это была бы **наша** агрегация под видом присланной.
|
||||
|
||||
Поэтому правило: **восстановление не обязано быть побайтным, оно обязано быть
|
||||
честным.** Каталог разрезов показывает, какие слои есть за какой период; после
|
||||
пересборки старый период честно объявляет один слой вместо трёх, а не
|
||||
притворяется, что ничего не изменилось.
|
||||
|
||||
### Устаревание нижнего слоя
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
## высокий
|
||||
- [Разбор метрик в часовые объекты](razbor-metrik-v-obekty.md) — Доставки копятся непрозрачными телами — точек в хранилище нет вовсе, всё остальное упирается в это
|
||||
- [Тренировки и секции с собственными id](trenirovki-i-zapisi.md) — Тренировки с геотреком и состояние разума приходят, но не разбираются — без них не закрыть ни трекер, ни агента-медика
|
||||
- [Пересборка хранилища из сырого архива](reindex-iz-arhiva.md) — Разбор будет ошибаться, а окно на исправление — 14 дней жизни архива
|
||||
- [Пересборка хранилища из сырого архива](reindex-iz-arhiva.md) — Ошибка разбора без пересборки становится потерей данных — исправленный код не применится к уже разобранному
|
||||
- [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое
|
||||
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
|
||||
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
|
||||
|
||||
@@ -3,14 +3,26 @@
|
||||
**Приоритет:** высокий
|
||||
|
||||
Разбор пишется по реальным данным и будет ошибаться — это норма, а не риск.
|
||||
Риск в другом: сырой архив живёт 14 дней, и окно на исправление ошибки равно
|
||||
этому сроку. Без `reindex` ошибка разбора становится потерей данных.
|
||||
Риск в другом: без пересборки ошибка разбора становится потерей данных —
|
||||
исправленный код не применится к тому, что уже разобрано неверно.
|
||||
|
||||
Пересчёт по всей истории сразу ещё и **точнее** приёма: вывод слоя и род
|
||||
агрегации на полном ряду доставок надёжнее, чем на одной.
|
||||
|
||||
Готово, когда `healthlog reindex` пересобирает хранилище с нуля из `data/raw`
|
||||
и результат совпадает с накопленным приёмом.
|
||||
Проектировать это надо сразу как **свёртку по журналу**, а не как разовую
|
||||
утилиту: состояние есть `import(снапшот экспорта) + replay(доставки после его
|
||||
даты)`, и пересборка из архива — вырожденный случай с пустым снапшотом. Тогда
|
||||
`reindex` и `import` окажутся одной операцией с разным входом, а не двумя
|
||||
похожими.
|
||||
|
||||
Отсюда требование, которое легко упустить: **свёртка обязана быть
|
||||
детерминированной.** Проигрывание должно давать то же состояние, что приём в
|
||||
реальном времени. Слияние «выигрывает более полная точка» коммутативно, но две
|
||||
одинаково полные точки с разными значениями разрешает порядок — значит
|
||||
воспроизведение идёт строго по `received_at`, а не по порядку файлов в каталоге.
|
||||
|
||||
Готово, когда пересборка с нуля даёт состояние, совпадающее с накопленным
|
||||
приёмом, и повторный прогон ничего не меняет.
|
||||
|
||||
Связано: план шаг 3, `docs/architecture.md` → «Сырой архив».
|
||||
|
||||
|
||||
@@ -5,10 +5,24 @@
|
||||
Срок жизни сырого архива объявлен (14 дней, `storage.raw_retention`), но
|
||||
удаления нет: архив растёт бесконечно. Пока это 16 МБ и проблемой не является.
|
||||
|
||||
Включать **после** того, как разбор устоится и `reindex` докажет, что
|
||||
хранилище действительно пересобирается: иначе страховка исчезнет раньше, чем
|
||||
**Само правило изменилось.** Экспорт Apple — снапшот всей истории, доставки
|
||||
после его даты — события поверх снапшота, и состояние всегда пересобираемо
|
||||
свёрткой. Значит доставки должны жить **до следующего проверенного экспорта**,
|
||||
а не фиксированные две недели: иначе между концом ретеншена и датой снапшота
|
||||
образуется дыра в журнале, и пересобрать этот отрезок будет нечем.
|
||||
|
||||
Цена измерена: ~23 МБ архива в сутки, то есть ~2 ГБ за квартал между
|
||||
экспортами. Дёшево за возможность пересобрать что угодно.
|
||||
|
||||
Отдельное исключение: `stateOfMind` в экспорт не попадает вовсе (проверено на
|
||||
свежем архиве). Для него доставки — не хвост журнала, а единственный источник,
|
||||
и под общее правило удаления он не подпадает.
|
||||
|
||||
Включать **после** того, как разбор устоится и пересборка докажет, что
|
||||
хранилище действительно восстанавливается: иначе страховка исчезнет раньше, чем
|
||||
перестанет быть нужна.
|
||||
|
||||
Готово, когда старые тела удаляются по расписанию, а `/stats` показывает
|
||||
глубину архива в днях.
|
||||
Готово, когда удаляются только доставки старше последнего проверенного
|
||||
экспорта, записи `stateOfMind` не трогаются вовсе, а `/stats` показывает
|
||||
глубину архива и дату снапшота, до которой он подрезан.
|
||||
|
||||
|
||||
@@ -1427,6 +1427,38 @@ RFC3339 Z 20 data.stateOfMind[].end = 2026-07-31T18:03:51
|
||||
То есть правило «экспорт говорит кодами» верно для типов записей и категориальных
|
||||
значений, но не для всего документа.
|
||||
|
||||
## 46. `stateOfMind` в экспорт не попадает — единственная дыра в журнале
|
||||
|
||||
Экспорт плюс доставки после его даты образуют полный журнал событий: состояние
|
||||
пересобирается свёрткой `import(снапшот) + replay(хвост)`. Проверка показала
|
||||
ровно одно исключение.
|
||||
|
||||
В свежем экспорте **нет ни одного типа со словом `StateOfMind`**:
|
||||
|
||||
```
|
||||
grep -oiE 'type="[^"]*(mind|mood|emotion)[^"]*"' → только
|
||||
type="HKCategoryTypeIdentifierMindfulSession" ← минуты осознанности, другое
|
||||
type="HKWorkoutEventTypeMotionPaused|Resumed" ← совпадение по подстроке
|
||||
```
|
||||
|
||||
При этом HAE состояние разума шлёт исправно, и шлёт правильно — стабильными
|
||||
кодами HealthKit (`momentary_emotion`, `slightly_pleasant`, `drained`), в
|
||||
отличие от переведённых фаз сна (находка 37).
|
||||
|
||||
**Следствие:** для `stateOfMind` доставки HAE — не хвост журнала, а
|
||||
единственный источник. Под общее правило ретеншена он не подпадает: удалив
|
||||
доставки, мы потеряем возможность восстановить его историю навсегда.
|
||||
|
||||
### Цена журнала
|
||||
|
||||
Поток даёт **~23 МБ сырого архива в сутки** (89 доставок за 16.4 часа дали
|
||||
15.6 МБ). При экспорте раз в 2–3 месяца это ~2 ГБ между снапшотами — дёшево за
|
||||
возможность пересобрать хранилище с любой точки.
|
||||
|
||||
Прежние 14 дней ретеншена были произвольным числом; правильный срок — до
|
||||
следующего проверенного экспорта, иначе между концом ретеншена и датой
|
||||
снапшота в журнале образуется дыра.
|
||||
|
||||
## Инструмент
|
||||
|
||||
Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная
|
||||
|
||||
+4
-4
@@ -14,7 +14,7 @@
|
||||
|
||||
Разведка закончена: правило вывода слоя, модель идентичности и формы точки
|
||||
проверены на живом потоке, выводы — в
|
||||
[local-research.md](local-research.md), 41 находка.
|
||||
[local-research.md](local-research.md), 46 находок.
|
||||
|
||||
## Шаги
|
||||
|
||||
@@ -73,9 +73,9 @@
|
||||
- ~~Пометка локализованных полей в схемах.~~ **Переросло в шаг 3:** одной
|
||||
пометки мало, нужен словарь кодов, иначе не сойтись с родным экспортом
|
||||
(находка 37).
|
||||
- **Ретеншен сырого архива** (`storage.raw_retention`, 14 дней) — удаление
|
||||
старых тел. Пока архив не подчищается; включить после того, как разбор
|
||||
устоится, иначе страховка исчезнет раньше, чем перестанет быть нужна.
|
||||
- **Ретеншен сырого архива** — удаление доставок старше последнего
|
||||
проверенного экспорта (не фиксированный срок: журнал не должен рваться).
|
||||
Пока архив не подчищается; включить после того, как разбор устоится.
|
||||
- **Порог `sealed`** — с какого возраста час считается запечатанным. Ставим по
|
||||
факту: сначала пишем `WARN` на изменение старых объектов и смотрим, какая
|
||||
глубина досчёта встречается в жизни (наблюдалось до 22 минут, находка 10).
|
||||
|
||||
Reference in New Issue
Block a user