непокрытые секции доставки видны в статусе разбора

- половина потока (50 доставок из 104) не несёт metrics вовсе и до сих пор
  числилась parsed: ретеншен, поверив статусу, срезал бы тела stateOfMind,
  которых в экспорте Apple нет
- разбор перечисляет верхнеуровневые ключи data, непокрытые проглатываются
  декодированием: тело 40 МиБ из непокрытой секции удерживает 0 МиБ
- статус partial и колонка delivery.uncovered_sections; миграция переводит
  прежние parsed в pending — им верить нельзя
- витрина не изменилась: отпечаток совпал с прогоном до изменения
This commit is contained in:
av
2026-08-01 21:25:59 +03:00
parent 7a7594e3e7
commit 34e5109b6d
26 changed files with 1808 additions and 89 deletions
+30
View File
@@ -234,6 +234,36 @@ HRV); у накопительных — только `date`. Поэтому то
безопасности, исход разбора виден в логе, в `delivery.parse_status` и в
`/stats`, а доразобрать их можно командой `reindex`.
#### Частичный разбор
Разбор покрывает секцию `metrics`; `workouts`, `stateOfMind`, `symptoms`, `ecg`
и прочие проходят мимо. Это половина потока: 48 доставок из 99 не несут
`metrics` вовсе (находка 50).
Такая доставка получает статус `partial`, а имена непокрытых секций — колонку
`delivery.uncovered_sections`. Статус отвечает на вопрос «разобрано ли всё»,
список — «что именно осталось»; спрашивать полагается статус. Без этого
различения `parsed` означал бы «разобрано» и для доставки, из которой не
прочитано ни байта, а ретеншен, поверив ему, срезал бы тело — необратимо для
`stateOfMind`, которого в экспорте Apple нет.
Перечисление идёт **в том же проходе**, что и разбор метрик: значение
непокрытой секции проглатывается декодированием в выбрасываемый `RawMessage`,
поэтому копия одна, живёт до следующего члена и удерживается ноль (измерено:
тело 40 МиБ, из которых почти всё — непокрытая секция, удерживает 0 МиБ).
Пропуск ручным счётом глубины по токенам этого не даёт: делимитеры идут мимо
сканера, ограничитель вложенности `encoding/json` не работает, и тело из
вложенных скобок съедает память вместо отказа.
`partial` — не отклонение, а установившееся состояние, поэтому уровень лога от
него не растёт. Постоянный `WARN` каждые пять минут обесценил бы уровень.
**Правило для будущих задач: покрыли секцию — пересверните.** Список это снимок
покрытия на момент свёртки; доставки, свёрнутые до того, как секция стала
покрытой, останутся `partial` со старым списком, и ретеншен будет вечно щадить
ненужные тела. Задача, которая начинает разбирать секцию, тем же изменением
переводит `partial`-строки с этим ключом в `pending`.
- **413** — тело больше допустимого. Граница стоит на **распакованном**
потоке, а не только на сжатом: `MaxBytesReader` поверх `r.Body` ограничивает
то, что приехало по сети, а в память попадает то, что из этого развернулось.
-1
View File
@@ -20,7 +20,6 @@
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
- [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
- [Непокрытые секции доставки видны в статусе разбора](nerazobrannye-sekcii-dostavki.md) — тело с одним stateOfMind числится parsed, а ретеншен снесёт его как разобранное — и в экспорте Apple его нет
- [Разнести ответ приёма и свёртку доставки](otvet-i-svyortka.md) — синхронная свёртка не помещается в write_timeout: широкие проходы получают обрыв вместо 200
## средний
@@ -1,60 +0,0 @@
# Непокрытые секции доставки видны в статусе разбора
**Приоритет:** высокий
Была блокером, вынутым ревью кода задачи `razbor-metrik-v-obekty` (профиль
`deep`, проход негативного пространства). **Решение принято** — ниже задача.
## Что не так сегодня
Разбор читает только `data.metrics`. Доставка, состоящая из `workouts`,
`stateOfMind`, `symptoms` или `ecg`, помечается `parse_status=parsed` с нулём
точек — неотличимо от доставки с пустой секцией метрик.
Само по себе некритично: секции пока не разбираются сознательно, тела лежат в
архиве. Опасность в **сцепке с ретеншеном**. Ретеншен по замыслу срезает архив
до следующего проверенного экспорта. Если он будет ориентироваться на
`parse_status`, он снесёт тела, которые числятся разобранными, — а для
`stateOfMind` это необратимо: **в экспорте Apple его нет** (находка 46),
доставки HAE для него единственный источник.
Цена ошибки здесь не «придётся пересобрать», а «истории состояния разума больше
не существует».
## Что решено
Вариант (1): **разбор возвращает список верхнеуровневых ключей `data`, которые
он не покрыл; статус доставки — `partial`.**
Почему он, а не альтернативы:
- Считать `parsed` только полностью разобранную доставку, а остальные держать
в `pending` — дёшево, но `pending` перестаёт означать «ещё не смотрели», и
подбор зависших доставок теряет свой признак. А подбор `pending` как раз
появляется задачей [otvet-i-svyortka](otvet-i-svyortka.md).
- Запретить ретеншену смотреть на `parse_status` — нулевая цена сейчас, но
ретеншен становится тупым и не защищает от «тело разобрано неверно, а мы его
уже срезали».
Список непокрытых ключей — дешёвая честность, и он же закрывает задачу «не
пропустить момент, когда поедет новая секция».
## Что делать
1. `hae.Parse`: перечислить верхнеуровневые ключи `data` и вернуть те, что
разбор не покрыл. Один `json.Decoder` по верхнему уровню, **без чтения
содержимого** — секции доходят до десятков МиБ.
2. Статус `partial` рядом с `parsed`/`failed`; миграция, если статус хранится
ограниченным набором.
3. Свёртка пишет непокрытые ключи в исход доставки и логирует их один раз —
именами ключей, без содержимого.
4. Тест на фикстуре с секцией, которой разбор не знает: статус `partial`,
ключ в списке, точки метрик при этом сохранены.
## Связано
- [retenshen-syrogo-arhiva](retenshen-syrogo-arhiva.md) — решить **до** неё.
- [proverka-novyh-sekcij](proverka-novyh-sekcij.md) — тот же признак закрывает
и её: момент появления новой секции становится событием в логе.
- [trenirovki-i-zapisi](trenirovki-i-zapisi.md) — по мере разбора секций список
непокрытых сокращается сам.
+7
View File
@@ -29,3 +29,10 @@
`docs/local-research.md` как не пришедшая, и ни одна не числится в ошибках
разбора.
## Что уже сделано
Разбор перечисляет непокрытые секции и пишет их в `delivery.uncovered_sections`
(change `2026-08-01-nerazobrannye-sekcii-dostavki`). Момент, когда поток принесёт
секцию, которой раньше не было, теперь **фиксируется** — остаётся научиться
замечать его активно: один `SELECT DISTINCT` по колонке даёт список всего, что
поток приносил, и сравнение с известным набором закрывает задачу.
+11
View File
@@ -26,3 +26,14 @@
экспорта, записи `stateOfMind` не трогаются вовсе, а `/stats` показывает
глубину архива и дату снапшота, до которой он подрезан.
## Предусловие снято
Признак, без которого ретеншен был опасен, готов: доставка с непокрытой секцией
имеет статус `partial` и список непокрытых ключей
(change `2026-08-01-nerazobrannye-sekcii-dostavki`). Ретеншен обязан спрашивать
статус, а не считать `parsed` разрешением: тело `stateOfMind` восстановить
неоткуда — в экспорте Apple секции нет.
Вместе с этим действует правило: задача, которая начинает разбирать секцию, тем
же изменением переводит `partial`-строки с этим ключом в `pending`. Ретеншену
позволено смотреть на `partial` только пока правило соблюдается.
+3 -1
View File
@@ -27,6 +27,7 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа
│ points INTEGER │ │ created_at TEXT │
│ headers TEXT │ │ updated_at TEXT │
│ derived_layer TEXT │ └──────────────────────────────┘
│ uncovered_sections TEXT │
└────────────────────────────┘
```
@@ -48,9 +49,10 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа
| `aggregation` | заголовок `automation-aggregation`. Режима **не означает**: значение `Default` наблюдалось у посекундного, минутного и часового режимов одновременно |
| `period` | заголовок периода (`Since Last Sync` и прочие) |
| `bytes`, `sha256` | размер и хеш тела; хеш пока только для учёта |
| `parse_status` | `pending` / `parsed` / `failed`. Код ответа приёма от него **не зависит**: сохранили — значит приняли |
| `parse_status` | `pending` / `parsed` / `partial` / `failed`. Код ответа приёма от него **не зависит**: сохранили — значит приняли. `pending` означает «ЭТИМ разбором ещё не смотрели», а не «тела не касались»: миграция 00005 перевела сюда доставки, разобранные кодом, который частичного разбора не различал |
| `points` | сколько точек дал разбор |
| `headers` | все заголовки запроса JSON-объектом, кроме несущих секреты |
| `uncovered_sections` | секции тела, которых разбор не покрыл, JSON-массивом имён; пустой список — `[]`. Ответ на вопрос «что останется потерянным, если тело удалить»: для `stateOfMind` он необратим, в экспорте Apple секции нет. Ретеншен обязан спрашивать его прежде, чем срезать тело |
| `derived_layer` | слой, выведенный для этой доставки. Нужен не отчётности, а самому выводу: доставка без плотных метрик наследует последний надёжно выведенный слой той же автоматизации, и без хранения этой памяти первая такая доставка после перезапуска осталась бы без слоя |
Индексы: `delivery_received_at` (порядок журнала), `delivery_sha256` (учёт
+27
View File
@@ -1631,6 +1631,33 @@ apple_stand_time 14
названа вслух и ограничена столкновением, где одно из двух содержимых на одних
координатах заведомо неверно.
## 50. Половина потока — не `metrics`, и секции не смешиваются
Замер по всем 99 доставкам архива: набор верхнеуровневых ключей `data`.
| набор ключей `data` | доставок |
|---|---|
| `metrics` | 51 |
| `workouts` | 24 |
| `stateOfMind` | 24 |
Три наблюдения, каждое из которых влияло на решение:
1. **48 доставок из 99 сейчас числятся разобранными, не будучи разобранными.**
Разбор читает только `metrics`; доставка из одних тренировок получала
`parse_status=parsed` с нулём точек — неотличимо от доставки с пустой
секцией метрик. Ретеншен, ориентируясь на статус, срезал бы тела, а для
`stateOfMind` это необратимо (находка 46).
2. **Ни одна доставка не несла двух секций сразу.** Автоматизация HAE шлёт одну
секцию за раз. Полагаться на это в правилах удаления данных, впрочем,
нельзя: наблюдение собрано за двое суток потока.
3. **Пустых секций не бывает** — все 99 значений непусты. Это снимает соблазн
«пустую секцию не считать непокрытой»: он бы снял шум, если бы HAE слал
`"workouts": []` в каждой доставке, а он не слал ни разу.
Отсюда статус `partial` и колонка `delivery.uncovered_sections`: статус
отвечает на вопрос «разобрано ли всё», список — «что именно осталось».
## Инструмент
Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная