непокрытые секции доставки видны в статусе разбора
- половина потока (50 доставок из 104) не несёт metrics вовсе и до сих пор числилась parsed: ретеншен, поверив статусу, срезал бы тела stateOfMind, которых в экспорте Apple нет - разбор перечисляет верхнеуровневые ключи data, непокрытые проглатываются декодированием: тело 40 МиБ из непокрытой секции удерживает 0 МиБ - статус partial и колонка delivery.uncovered_sections; миграция переводит прежние parsed в pending — им верить нельзя - витрина не изменилась: отпечаток совпал с прогоном до изменения
This commit is contained in:
@@ -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` ограничивает
|
||||
то, что приехало по сети, а в память попадает то, что из этого развернулось.
|
||||
|
||||
@@ -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) — по мере разбора секций список
|
||||
непокрытых сокращается сам.
|
||||
@@ -29,3 +29,10 @@
|
||||
`docs/local-research.md` как не пришедшая, и ни одна не числится в ошибках
|
||||
разбора.
|
||||
|
||||
## Что уже сделано
|
||||
|
||||
Разбор перечисляет непокрытые секции и пишет их в `delivery.uncovered_sections`
|
||||
(change `2026-08-01-nerazobrannye-sekcii-dostavki`). Момент, когда поток принесёт
|
||||
секцию, которой раньше не было, теперь **фиксируется** — остаётся научиться
|
||||
замечать его активно: один `SELECT DISTINCT` по колонке даёт список всего, что
|
||||
поток приносил, и сравнение с известным набором закрывает задачу.
|
||||
|
||||
@@ -26,3 +26,14 @@
|
||||
экспорта, записи `stateOfMind` не трогаются вовсе, а `/stats` показывает
|
||||
глубину архива и дату снапшота, до которой он подрезан.
|
||||
|
||||
## Предусловие снято
|
||||
|
||||
Признак, без которого ретеншен был опасен, готов: доставка с непокрытой секцией
|
||||
имеет статус `partial` и список непокрытых ключей
|
||||
(change `2026-08-01-nerazobrannye-sekcii-dostavki`). Ретеншен обязан спрашивать
|
||||
статус, а не считать `parsed` разрешением: тело `stateOfMind` восстановить
|
||||
неоткуда — в экспорте Apple секции нет.
|
||||
|
||||
Вместе с этим действует правило: задача, которая начинает разбирать секцию, тем
|
||||
же изменением переводит `partial`-строки с этим ключом в `pending`. Ретеншену
|
||||
позволено смотреть на `partial` только пока правило соблюдается.
|
||||
|
||||
+3
-1
@@ -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` (учёт
|
||||
|
||||
@@ -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, только стандартная
|
||||
|
||||
Reference in New Issue
Block a user