хранилище описано как свёртка по журналу событий

- экспорт Apple это снапшот всей истории, доставки после его даты — события
  поверх; состояние пересобирается как import(экспорт) + replay(доставки)
- отсюда ретеншен архива меняется с произвольных 14 дней на «до следующего
  проверенного экспорта» (~2 ГБ за квартал, измерено), а свёртка обязана быть
  детерминированной — воспроизведение строго по received_at
- названы границы модели: stateOfMind в экспорт не попадает вовсе, а верхние
  слои за периоды с удалёнными доставками не воскресают и досчитываться не
  должны — каталог обязан говорить это честно
This commit is contained in:
av
2026-08-01 14:27:13 +03:00
parent 7ee55057e0
commit b2885c79e3
10 changed files with 162 additions and 46 deletions
+60 -12
View File
@@ -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` технически можно — и нельзя
по инварианту: это была бы **наша** агрегация под видом присланной.
Поэтому правило: **восстановление не обязано быть побайтным, оно обязано быть
честным.** Каталог разрезов показывает, какие слои есть за какой период; после
пересборки старый период честно объявляет один слой вместо трёх, а не
притворяется, что ничего не изменилось.
### Устаревание нижнего слоя