feat: разбор и хранение тренировок и состояния разума
- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`; миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки с этими ключами - сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА — «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина расходилась бы с пересборкой молча - отпечаток витрины покрывает тренировки и записи и снимается одним снимком базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
## 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-tochki`. Два эндпоинта,
|
||||
введённые раньше конверта, задали бы контракт мимоходом.
|
||||
- **Секции, которых поток не приносил** (`ecg`, `symptoms`, `cycleTracking`,
|
||||
`medications`, `heartRateNotifications`). Модель под них закладывается —
|
||||
таблица `record` ключуется родом секции, — но разбор не пишется вслепую: их
|
||||
формы никто не видел, а задача `proverka-novyh-sekcij` существует ровно про
|
||||
момент, когда они появятся.
|
||||
- **Разворачивание маршрута** в таблицу точек — отдельная идея беклога, у неё нет
|
||||
клиента.
|
||||
- **Словарь категориальных значений** (`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`, «Предел порядка назван вслух»; открытый блокер
|
||||
`poryadok-zhurnala-na-priyome.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). До этой задачи защита
|
||||
работала побочным эффектом непокрытости. Ретеншена в проекте нет, поэтому здесь
|
||||
ничего не ломается сегодня; но предусловие, которое задача
|
||||
`retenshen-syrogo-arhiva` считала снятым, снова открыто, и это записывается в
|
||||
её файл тем же изменением.
|
||||
|
||||
### 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`:
|
||||
|
||||
1. `CREATE TABLE workout` и `CREATE TABLE record` с индексами по времени.
|
||||
2. `UPDATE delivery SET parse_status = 'pending'` для строк, чей
|
||||
`uncovered_sections` содержит `workouts` или `stateOfMind`.
|
||||
|
||||
Откат (`Down`) снимает таблицы; восстановление содержимого — обычная пересборка
|
||||
из архива, витрина производна по построению. Строки, переведённые в `pending`,
|
||||
`Down` обратно не возвращает: какими они были, восстановить неоткуда, а
|
||||
`pending` консервативен — ретеншен его не трогает.
|
||||
|
||||
Живой сервис миграцию переживает: новые таблицы никого не блокируют, `UPDATE`
|
||||
идёт по 118 строкам.
|
||||
Reference in New Issue
Block a user