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

- половина потока (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, только стандартная
+46 -8
View File
@@ -82,6 +82,12 @@ type Stats struct {
LayerMismatch bool
Collisions []store.Collision
IncomparableAt []store.Collision
// Uncovered — верхнеуровневые ключи `data`, которых разбор не покрывает.
// Это ответ на вопрос «что останется потерянным, если тело удалить»:
// для stateOfMind он необратим — в экспорте Apple этой секции нет.
Uncovered []string
// UncoveredDropped — сколько имён отброшено границей списка.
UncoveredDropped int
}
// Fold разбирает тело доставки и раскладывает точки по часовым объектам.
@@ -103,7 +109,7 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (Stats, error) {
body, err := s.readBody(d.RawPath)
if err != nil {
s.fail(ctx, deliveryID, err)
s.fail(ctx, deliveryID, err, nil)
return stats, err
}
@@ -118,10 +124,15 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (Stats, error) {
FallbackLayer: hae.Layer(fallback),
})
if err != nil {
s.fail(ctx, deliveryID, err)
// Список непокрытых секций переживает отказ: доставка, у которой не
// определился слой, обязана остаться записью о том, что в теле есть
// невосстановимая секция.
s.fail(ctx, deliveryID, err, parsed.Uncovered)
return stats, err
}
stats.Uncovered = parsed.Uncovered
stats.UncoveredDropped = parsed.UncoveredDropped
stats.Metrics = parsed.Metrics
stats.Points = len(parsed.Points)
stats.SkippedNoTime = parsed.SkippedNoTime
@@ -132,7 +143,7 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (Stats, error) {
merge, err := s.store.MergePoints(ctx, toIncoming(parsed.Points), deliveryID)
if err != nil {
s.fail(ctx, deliveryID, err)
s.fail(ctx, deliveryID, err, parsed.Uncovered)
return stats, err
}
@@ -146,7 +157,20 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (Stats, error) {
stats.Collisions = merge.Collisions
stats.IncomparableAt = merge.IncomparableAt
if err := s.finish(ctx, deliveryID, store.ParseDone, int64(stats.Points), stats.Layer); err != nil {
// Источник истины — список; статус производен от него и от факта отказа.
// Приоритет назван явно, иначе два будущих читателя (ретеншен и /stats)
// разойдутся: один спросит parse_status, другой — непустоту списка.
status := store.ParseDone
if len(parsed.Uncovered) > 0 {
status = store.ParsePartial
}
out := store.ParseOutcome{
Status: status,
Points: int64(stats.Points),
Layer: stats.Layer,
Uncovered: parsed.Uncovered,
}
if err := s.finish(ctx, deliveryID, out); err != nil {
s.log.ErrorContext(ctx, "delivery fold failed", "error", err, "delivery_id", deliveryID)
return stats, err
}
@@ -186,6 +210,11 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
"skipped_bad_end", st.SkippedBadEnd,
"layer", st.Layer,
"layer_mismatch", st.LayerMismatch,
// Структурным []string, а не склейкой: JSON-кодировщик slog экранирует
// управляющие символы, поэтому имя секции из чужого тела не разрывает
// построчный разбор логов. Содержимого секций здесь нет.
"uncovered", st.Uncovered,
"uncovered_dropped", st.UncoveredDropped,
}
if len(st.Collisions) > 0 {
attrs = append(attrs, "collisions", formatCollisions(st.Collisions))
@@ -200,6 +229,10 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
allSkipped := st.Points == 0 && skipped > 0
switch {
case st.UncoveredDropped > 0:
// Не частичный разбор, а тело, не похожее на HAE: секций у HAE восемь,
// а границу выбило больше тридцати двух.
s.log.WarnContext(ctx, "delivery folded, uncovered section list truncated", attrs...)
case st.Incomparable > 0:
// Выше перезаписей намеренно: несравнимый набор полей — событие реже и
// информативнее, на живом потоке не случавшееся ни разу. Признаки при
@@ -239,11 +272,11 @@ func formatCollisions(cs []store.Collision) string {
const keepLayer = ""
// finish записывает исход разбора на контексте, переживающем отмену исходного.
func (s *Service) finish(ctx context.Context, deliveryID, status string, points int64, layer string) error {
func (s *Service) finish(ctx context.Context, deliveryID string, out store.ParseOutcome) error {
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), finishTimeout)
defer cancel()
if err := s.store.FinishParse(ctx, deliveryID, status, points, layer); err != nil {
if err := s.store.FinishParse(ctx, deliveryID, out); err != nil {
return fmt.Errorf("запись исхода разбора: %w", err)
}
return nil
@@ -251,7 +284,7 @@ func (s *Service) finish(ctx context.Context, deliveryID, status string, points
// fail отмечает доставку неразобранной. Тело остаётся в архиве, и её подберёт
// пересборка — приём при этом не затрагивается: сохранили значит приняли.
func (s *Service) fail(ctx context.Context, deliveryID string, cause error) {
func (s *Service) fail(ctx context.Context, deliveryID string, cause error, uncovered []string) {
level := slog.LevelError
switch {
case errors.Is(cause, hae.ErrLayerUnknown):
@@ -269,7 +302,12 @@ func (s *Service) fail(ctx context.Context, deliveryID string, cause error) {
// Слой НЕ затирается: доставка могла свернуться успешно раньше, и пустая
// строка здесь оборвала бы цепочку наследования, то есть изменила бы
// результат пересборки журнала.
if err := s.finish(ctx, deliveryID, store.ParseFailed, 0, keepLayer); err != nil {
out := store.ParseOutcome{
Status: store.ParseFailed,
Layer: keepLayer,
Uncovered: uncovered,
}
if err := s.finish(ctx, deliveryID, out); err != nil {
s.log.ErrorContext(ctx, "delivery parse status not recorded", "error", err, "delivery_id", deliveryID)
}
}
+96
View File
@@ -5,6 +5,7 @@ import (
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -228,3 +229,98 @@ func mustHour(t *testing.T, st *store.Store, metric, layer string) time.Time {
}
return hours[0]
}
// Доставка с непокрытой секцией обязана быть ОТЛИЧИМА от разобранной целиком.
// Без этого ретеншен, ориентируясь на статус, срежет тело — а для stateOfMind
// это необратимо: в экспорте Apple секции нет, доставки HAE единственный
// источник.
func TestFoldЧастичныйРазборВиденВУчёте(t *testing.T) {
t.Parallel()
f, arch, st := newFold(t)
ctx := context.Background()
deliver(t, arch, st, "d1", "Minutes", "a1", fixture(t, "uncovered_sections.json"))
stats, err := f.Fold(ctx, "d1")
if err != nil {
t.Fatalf("свёртка: %v", err)
}
if len(stats.Uncovered) == 0 {
t.Error("список непокрытых секций пуст")
}
d, err := st.LastDelivery(ctx)
if err != nil {
t.Fatalf("чтение доставки: %v", err)
}
if d.ParseStatus != store.ParsePartial {
t.Errorf("статус %q, ожидался %q", d.ParseStatus, store.ParsePartial)
}
if !strings.Contains(d.UncoveredSections, "stateOfMind") {
t.Errorf("список в базе %q не содержит stateOfMind", d.UncoveredSections)
}
// Точки метрик обязаны сохраниться: частичность не отменяет разобранного.
if stats.Points == 0 {
t.Error("точек нет — покрытая секция потерялась вместе с непокрытой")
}
}
// Доставка из одних метрик списка не получает и остаётся parsed: частичность
// производна от списка, а не назначена.
func TestFoldПолныйРазборОстаётсяParsed(t *testing.T) {
t.Parallel()
f, arch, st := newFold(t)
ctx := context.Background()
deliver(t, arch, st, "d1", "Minutes", "a1", fixture(t, "minute.json"))
if _, err := f.Fold(ctx, "d1"); err != nil {
t.Fatalf("свёртка: %v", err)
}
d, err := st.LastDelivery(ctx)
if err != nil {
t.Fatalf("чтение доставки: %v", err)
}
if d.ParseStatus != store.ParseDone {
t.Errorf("статус %q, ожидался %q", d.ParseStatus, store.ParseDone)
}
if d.UncoveredSections != "[]" {
t.Errorf("список %q, ожидался `[]` — ровно одно представление пустоты", d.UncoveredSections)
}
}
// Список замещает прежнее значение ЦЕЛИКОМ, включая замещение пустым. Иначе
// доставка, чья секция стала покрытой, осталась бы partial навсегда, и
// ретеншен вечно щадил бы тело, которое уже не нужно.
func TestFoldПересвёрткаОчищаетСписок(t *testing.T) {
t.Parallel()
f, arch, st := newFold(t)
ctx := context.Background()
// Сперва доставка с непокрытой секцией.
deliver(t, arch, st, "d1", "Minutes", "a1", fixture(t, "uncovered_sections.json"))
if _, err := f.Fold(ctx, "d1"); err != nil {
t.Fatalf("свёртка: %v", err)
}
// Затем — та же доставка, но тело уже без непокрытых секций: так выглядит
// пересвёртка после того, как секцию научились разбирать.
deliver(t, arch, st, "d2", "Minutes", "a1", fixture(t, "minute.json"))
if _, err := f.Fold(ctx, "d2"); err != nil {
t.Fatalf("свёртка: %v", err)
}
d, err := st.LastDelivery(ctx)
if err != nil {
t.Fatalf("чтение доставки: %v", err)
}
if d.UncoveredSections != "[]" {
t.Errorf("список %q не очистился — пустой список обязан замещать прежний", d.UncoveredSections)
}
if d.ParseStatus != store.ParseDone {
t.Errorf("статус %q, ожидался %q", d.ParseStatus, store.ParseDone)
}
}
+72
View File
@@ -182,3 +182,75 @@ func TestFoldНесравнимыеНаборыДаютWarn(t *testing.T) {
}
}
}
// Имена непокрытых секций в логе нужны — по ним видно, что поток принёс новое.
// Содержимого секций там быть не может: это данные о здоровье. И уровень от
// самой частичности не растёт — `partial` установившееся состояние половины
// потока, а постоянный WARN каждые пять минут обесценивает уровень.
func TestFoldЧастичныйРазборВЛоге(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
dir := t.TempDir()
arch, err := archive.New(filepath.Join(dir, "raw"))
if err != nil {
t.Fatalf("архив: %v", err)
}
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
if err != nil {
t.Fatalf("база: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
f := fold.New(arch, st, 0, log)
ctx := context.Background()
// Содержимое непокрытой секции помечено так, чтобы его нельзя было спутать
// ни с чем: если оно окажется в логе, тест это увидит.
const body = `{"data":{
"metrics":[{"name":"step_count","units":"count","data":[
{"date":"2025-06-05 10:00:00 +0300","qty":1},
{"date":"2025-06-05 10:01:00 +0300","qty":2},
{"date":"2025-06-05 10:02:00 +0300","qty":3},
{"date":"2025-06-05 10:03:00 +0300","qty":4},
{"date":"2025-06-05 10:04:00 +0300","qty":5},
{"date":"2025-06-05 10:05:00 +0300","qty":6},
{"date":"2025-06-05 10:06:00 +0300","qty":7},
{"date":"2025-06-05 10:07:00 +0300","qty":8},
{"date":"2025-06-05 10:08:00 +0300","qty":9},
{"date":"2025-06-05 10:09:00 +0300","qty":10}]}],
"stateOfMind":[{"valence":"СЕКРЕТНОЕ-НАСТРОЕНИЕ"}]}}`
deliver(t, arch, st, "d1", "Minutes", "a1", []byte(body))
if _, err := f.Fold(ctx, "d1"); err != nil {
t.Fatalf("свёртка: %v", err)
}
out := buf.String()
if !strings.Contains(out, "stateOfMind") {
t.Error("имени непокрытой секции нет в логе — момент появления новой секции незаметен")
}
if strings.Contains(out, "СЕКРЕТНОЕ-НАСТРОЕНИЕ") {
t.Error("содержимое непокрытой секции утекло в лог")
}
var rec struct {
Level string `json:"level"`
Uncovered []string `json:"uncovered"`
}
line := strings.TrimSpace(out)
if i := strings.LastIndex(line, "\n"); i >= 0 {
line = line[i+1:]
}
if err := json.Unmarshal([]byte(line), &rec); err != nil {
t.Fatalf("запись лога не разбирается: %v", err)
}
if rec.Level != "INFO" {
t.Errorf("уровень %q, ожидался INFO: частичность — не отклонение", rec.Level)
}
if len(rec.Uncovered) != 1 || rec.Uncovered[0] != "stateOfMind" {
t.Errorf("атрибут uncovered = %v, ожидался структурный список из stateOfMind", rec.Uncovered)
}
}
+16
View File
@@ -39,6 +39,8 @@ func TestReplayЖивогоАрхива(t *testing.T) {
f, arch, st := newFold(t)
ctx := context.Background()
partial := 0
sections := map[string]int{}
var folded, failed, incomparable int
for _, path := range bodies {
@@ -62,6 +64,12 @@ func TestReplayЖивогоАрхива(t *testing.T) {
}
folded++
incomparable += res.Incomparable
if len(res.Uncovered) > 0 {
partial++
for _, s := range res.Uncovered {
sections[s]++
}
}
}
// Несравнимые наборы полей — посылка, на которой стоит отказ от объединения
@@ -70,11 +78,19 @@ func TestReplayЖивогоАрхива(t *testing.T) {
// сходимости.
t.Logf("доставок %d: свёрнуто %d, не свёрнуто %d, несравнимых наборов %d",
len(bodies), folded, failed, incomparable)
t.Logf("частично разобрано %d, непокрытые секции: %v", partial, sections)
if folded == 0 {
t.Fatal("ни одна доставка не свернулась")
}
// Половина живого потока не несёт metrics вовсе (находка 50): такие
// доставки обязаны быть отличимы от разобранных целиком, иначе ретеншен
// срежет тела, которые для stateOfMind единственный источник.
if partial == 0 {
t.Error("ни одной частично разобранной доставки — перечисление непокрытых секций не работает")
}
// Повторный прогон того же журнала не меняет состояния: свёртка
// детерминирована, и пересборка даёт то же, что живой приём.
//
+206 -12
View File
@@ -17,7 +17,9 @@ import (
"encoding/json"
"errors"
"fmt"
"sort"
"time"
"unicode/utf8"
)
// Layer — подробность, в которой метрика приехала. Выводится из выравнивания
@@ -99,6 +101,19 @@ type Point struct {
type Result struct {
Points []Point
// Uncovered — верхнеуровневые ключи `data`, которых разбор не покрывает,
// отсортированные и без повторов. Половина живого потока состоит из таких
// доставок целиком (48 из 99: workouts и stateOfMind), и без этого списка
// они неотличимы от разобранной доставки с пустой секцией метрик.
//
// Список канонизирован потому, что уезжает в базу и сравнивается между
// доставками, а порядок ключей в JSON от HAE нестабилен.
Uncovered []string
// UncoveredDropped — сколько имён отброшено границей списка. Молчаливое
// усечение сделало бы список уверенным, но неполным ответом на вопрос «что
// останется потерянным, если тело удалить».
UncoveredDropped int
// Metrics — сколько метрик встретилось в секции.
Metrics int
// SkippedNoTime — точки без разбираемой метки времени.
@@ -155,10 +170,12 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
}
}()
metrics, err := decodeMetrics(body)
metrics, uncovered, dropped, err := decodeEnvelope(body)
if err != nil {
return Result{}, err
}
res.Uncovered = uncovered
res.UncoveredDropped = dropped
res.Metrics = len(metrics)
if len(metrics) == 0 {
@@ -188,7 +205,15 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
res.HeaderLayer = headerLayer(meta.Aggregation)
if err := assignLayers(groups, meta, &res); err != nil {
return Result{Metrics: res.Metrics}, err
// Список непокрытых секций переживает отказ: доставка, у которой не
// определился слой, обязана остаться записью о том, что в теле есть
// невосстановимая секция. Иначе ретеншен увидит failed без списка и
// решит, что терять нечего.
return Result{
Metrics: res.Metrics,
Uncovered: res.Uncovered,
UncoveredDropped: res.UncoveredDropped,
}, err
}
total := 0
@@ -237,12 +262,27 @@ type group struct {
// 42 МиБ через map[string]any удерживает 197 МиБ кучи против 54 МиБ у этой
// формы. Вместе с самим телом пик доходил бы до ~300 МиБ на доставку — это
// OOM ровно на пике потока, когда терять доставки дороже всего.
type envelope struct {
Data struct {
Metrics []metricEnvelope `json:"metrics"`
} `json:"data"`
// metricsSection — единственная секция, которую разбор покрывает сегодня.
const metricsSection = "metrics"
// covered говорит, покрывает ли разбор секцию с таким именем.
//
// Функция, а не изменяемая карта: разбор и перечисление непокрытых ходят по
// одному источнику, поэтому состояние «секция разбирается, но числится
// непокрытой» невыразимо.
func covered(section string) bool {
return section == metricsSection
}
// Границы на список непокрытых ключей. Тело контролирует отправитель целиком:
// без границ сто тысяч однобуквенных ключей превращаются в одну строку в базе
// и одну строку в логе того же порядка. Секций у HAE восемь, самое длинное имя
// — heartRateNotifications (22 байта), так что запас велик.
const (
maxUncovered = 32
maxUncoveredLen = 64
)
type metricEnvelope struct {
Name string `json:"name"`
Units string `json:"units"`
@@ -261,13 +301,167 @@ type pointHead struct {
TotalSleep *json.RawMessage `json:"totalSleep"`
}
func decodeMetrics(body []byte) ([]metricEnvelope, error) {
var env envelope
dec := json.NewDecoder(bytes.NewReader(body))
if err := dec.Decode(&env); err != nil {
return nil, fmt.Errorf("%w: %v", ErrMalformed, err) //nolint:errorlint // причина уходит в лог, наружу не раскрывается
// decodeEnvelope разбирает конверт: отдаёт секцию metrics и имена секций,
// которых разбор не покрывает.
//
// Идёт по верхнему уровню одним декодером: Token() читает рамку объекта и имена
// членов, Decode() — значения. Значение покрытого ключа декодируется на месте,
// значение непокрытого ПРОГЛАТЫВАЕТСЯ декодированием в выбрасываемый
// RawMessage. Это форма из ExampleDecoder_Decode_stream стандартной библиотеки;
// в encoding/json/v2 та же операция названа прямо — SkipValue.
//
// Пропуск ручным счётом глубины по Token() выглядит дешевле и измеримо хуже:
// делимитеры идут мимо сканера, поэтому ограничитель вложенности encoding/json
// не работает, а стек токенов растёт как O(глубины). Тело 40 МиБ из вложенных
// скобок даёт пик 488 МиБ вместо контрактных четырёх тел — Decode отвергает его
// мгновенно. Разбор `data` в map[string]json.RawMessage дешевле по коду, но
// копирует байты ВСЕХ секций и держит их до конца разбора; у проглатывания
// копия одна и живёт до следующего члена.
func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string, dropped int, err error) {
fail := func(e error) ([]metricEnvelope, []string, int, error) {
return nil, nil, 0, fmt.Errorf("%w: %v", ErrMalformed, e) //nolint:errorlint // причина уходит в лог, наружу не раскрывается
}
return env.Data.Metrics, nil
dec := json.NewDecoder(bytes.NewReader(body))
// Верхний уровень тела: интересует только data. Прочие ключи конверта в
// список не идут — иначе в одном списке смешались бы имена секций и мусор
// конверта, а форму `{"data": …}` проверяет приём.
tok, err := dec.Token()
if err != nil {
return fail(err)
}
// Голый null телом ошибкой не был и не становится: прежний разбор
// раскладывал его в пустую структуру. Границы поведения этой задачей не
// двигаются — она добавляет список, а не строгость.
if tok == nil {
return nil, nil, 0, nil
}
if d, ok := tok.(json.Delim); !ok || d != '{' {
return fail(fmt.Errorf("ожидался объект, встречено %v", tok))
}
seen := make(map[string]struct{})
for dec.More() {
name, err := memberName(dec)
if err != nil {
return fail(err)
}
if name != "data" {
if err := swallow(dec); err != nil {
return fail(err)
}
continue
}
metrics, uncovered, dropped, err = decodeData(dec, seen)
if err != nil {
return fail(err)
}
}
if err := expectDelim(dec, '}'); err != nil {
return fail(err)
}
// Список канонизируется: порядок ключей в JSON от HAE нестабилен, а
// значение уезжает в базу и сравнивается между доставками.
sort.Strings(uncovered)
return metrics, uncovered, dropped, nil
}
// decodeData разбирает объект data, собирая metrics и имена непокрытых секций.
func decodeData(dec *json.Decoder, seen map[string]struct{}) ([]metricEnvelope, []string, int, error) {
tok, err := dec.Token()
if err != nil {
return nil, nil, 0, err
}
// data не объект — прежнее поведение: ошибка ровно там, где была.
if d, ok := tok.(json.Delim); !ok || d != '{' {
return nil, nil, 0, fmt.Errorf("data: ожидался объект, встречено %v", tok)
}
var (
metrics []metricEnvelope
uncovered []string
dropped int
)
for dec.More() {
name, err := memberName(dec)
if err != nil {
return nil, nil, 0, err
}
if covered(name) {
// Повтор ключа metrics JSON допускает; секции ОБЪЕДИНЯЮТСЯ, а не
// побеждает последняя: терять точки молча нельзя.
var part []metricEnvelope
if err := dec.Decode(&part); err != nil {
return nil, nil, 0, err
}
metrics = append(metrics, part...)
continue
}
if err := swallow(dec); err != nil {
return nil, nil, 0, err
}
if _, dup := seen[name]; dup {
continue
}
seen[name] = struct{}{}
if len(uncovered) >= maxUncovered {
dropped++
continue
}
uncovered = append(uncovered, clipSection(name))
}
if _, err := dec.Token(); err != nil { // закрывающая скобка data
return nil, nil, 0, err
}
return metrics, uncovered, dropped, nil
}
// memberName читает имя члена объекта. Token() отдаёт имя уже после разбора
// escape-последовательностей, поэтому границы считаются по декодированному.
func memberName(dec *json.Decoder) (string, error) {
tok, err := dec.Token()
if err != nil {
return "", err
}
name, ok := tok.(string)
if !ok {
return "", fmt.Errorf("ожидалось имя члена, встречено %v", tok)
}
return name, nil
}
// swallow проглатывает значение целиком, ничего не удерживая.
func swallow(dec *json.Decoder) error {
var skip json.RawMessage
return dec.Decode(&skip)
}
func expectDelim(dec *json.Decoder, want json.Delim) error {
tok, err := dec.Token()
if err != nil {
return err
}
if d, ok := tok.(json.Delim); !ok || d != want {
return fmt.Errorf("ожидалось %q, встречено %v", want, tok)
}
return nil
}
// clipSection обрезает слишком длинное имя по границе рун и помечает обрезку.
// Маркер приписывается СВЕРХ предела: обрезка не инъективна, и обрезанное имя
// сравнению со словарём известных секций не подлежит.
func clipSection(name string) string {
if len(name) <= maxUncoveredLen {
return name
}
cut := maxUncoveredLen
for cut > 0 && !utf8.RuneStart(name[cut]) {
cut--
}
return name[:cut] + "…"
}
// decodeGroup разбирает точки одной метрики. Точка без разбираемой метки
+157
View File
@@ -3,11 +3,14 @@ package hae_test
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"unicode/utf8"
"git.vakhrushev.me/av/healthlog/internal/hae"
)
@@ -544,3 +547,157 @@ func FuzzParse(f *testing.F) {
}
})
}
// Половина живого потока состоит из непокрытых секций целиком (48 доставок из
// 99: workouts и stateOfMind). Без списка они неотличимы от разобранной
// доставки с пустой секцией метрик, и ретеншен, ориентируясь на статус, срезал
// бы тела, которые для stateOfMind единственный источник.
func TestParseПеречисляетНепокрытыеСекции(t *testing.T) {
t.Parallel()
res, err := hae.Parse(load(t, "uncovered_sections.json"), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
want := []string{"stateOfMind", "workouts"}
if !slices.Equal(res.Uncovered, want) {
t.Errorf("непокрытые %v, ожидались %v", res.Uncovered, want)
}
if len(res.Points) == 0 {
t.Error("точки метрик обязаны сохраниться: непокрытая секция рядом им не мешает")
}
if res.UncoveredDropped != 0 {
t.Errorf("отброшено %d имён, ожидалось 0", res.UncoveredDropped)
}
}
func TestParseНепокрытыеСекцииГраницыИДетерминизм(t *testing.T) {
t.Parallel()
t.Run("доставка из одной непокрытой секции", func(t *testing.T) {
t.Parallel()
res, err := hae.Parse([]byte(`{"data":{"stateOfMind":[{"x":1}]}}`), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if !slices.Equal(res.Uncovered, []string{"stateOfMind"}) {
t.Errorf("непокрытые %v", res.Uncovered)
}
if len(res.Points) != 0 {
t.Errorf("точек %d, ожидалось 0", len(res.Points))
}
})
t.Run("доставка из одних метрик списка не даёт", func(t *testing.T) {
t.Parallel()
res, err := hae.Parse([]byte(`{"data":{"metrics":[]}}`), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if len(res.Uncovered) != 0 {
t.Errorf("непокрытые %v, ожидался пустой список", res.Uncovered)
}
})
// Порядок ключей в JSON от HAE нестабилен, а список уезжает в базу и
// сравнивается между доставками: зависящий от порядка на проводе список
// сравнивать нельзя.
t.Run("порядок и повторы не влияют", func(t *testing.T) {
t.Parallel()
bodies := []string{
`{"data":{"workouts":[],"stateOfMind":[],"ecg":[]}}`,
`{"data":{"ecg":[],"workouts":[],"stateOfMind":[]}}`,
`{"data":{"stateOfMind":[],"ecg":[],"workouts":[],"ecg":[]}}`,
}
want := []string{"ecg", "stateOfMind", "workouts"}
for _, b := range bodies {
res, err := hae.Parse([]byte(b), hae.Meta{})
if err != nil {
t.Fatalf("разбор %s: %v", b, err)
}
if !slices.Equal(res.Uncovered, want) {
t.Errorf("тело %s дало %v, ожидалось %v", b, res.Uncovered, want)
}
}
})
// Повтор ключа metrics JSON допускает; секции объединяются, а не побеждает
// последняя — терять точки молча нельзя.
t.Run("повтор metrics объединяет секции", func(t *testing.T) {
t.Parallel()
const body = `{"data":{"metrics":[{"name":"a","data":[]}],"metrics":[{"name":"b","data":[]}]}}`
res, err := hae.Parse([]byte(body), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if res.Metrics != 2 {
t.Errorf("метрик %d, ожидалось 2 — секции обязаны объединиться", res.Metrics)
}
})
t.Run("границы списка видны", func(t *testing.T) {
t.Parallel()
var sb strings.Builder
sb.WriteString(`{"data":{`)
for i := range 40 {
if i > 0 {
sb.WriteByte(',')
}
fmt.Fprintf(&sb, `"s%02d":[]`, i)
}
sb.WriteString(`}}`)
res, err := hae.Parse([]byte(sb.String()), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if len(res.Uncovered) != 32 {
t.Errorf("имён %d, ожидалось 32", len(res.Uncovered))
}
if res.UncoveredDropped != 8 {
t.Errorf("отброшено %d, ожидалось 8 — иначе «ровно 32» неотличимо от «пришло пятьсот»", res.UncoveredDropped)
}
})
t.Run("длинное имя обрезано и помечено", func(t *testing.T) {
t.Parallel()
long := strings.Repeat("щ", 60) // 120 байт
res, err := hae.Parse([]byte(`{"data":{"`+long+`":[]}}`), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if len(res.Uncovered) != 1 {
t.Fatalf("непокрытые %v", res.Uncovered)
}
got := res.Uncovered[0]
if !strings.HasSuffix(got, "…") {
t.Errorf("имя %q без маркера обрезки: обрезка не инъективна и обязана быть видимой", got)
}
if !utf8.ValidString(got) {
t.Errorf("имя %q обрезано посреди руны", got)
}
})
// Ошибка после разобранной секции metrics: правило прежнее — при ошибке
// точек нет вовсе, иначе часть точек оказалась бы в витрине под статусом,
// по которому доставку никто не подберёт.
t.Run("обрыв после метрик точек не отдаёт", func(t *testing.T) {
t.Parallel()
const body = `{"data":{"metrics":[{"name":"a","data":[{"date":"2025-06-05 10:00:00 +0300","qty":1}]}],"work`
res, err := hae.Parse([]byte(body), hae.Meta{})
if !errors.Is(err, hae.ErrMalformed) {
t.Fatalf("ошибка %v, ожидалась ErrMalformed", err)
}
if len(res.Points) != 0 {
t.Errorf("точек %d, ожидалось 0", len(res.Points))
}
})
}
+76
View File
@@ -82,3 +82,79 @@ func largeBody(metrics, points int) []byte {
b.WriteString(`]}}`)
return []byte(b.String())
}
// Перечисление непокрытых секций не имеет права стать вторым способом удержать
// тело. Непокрытая секция ПРОГЛАТЫВАЕТСЯ декодированием в выбрасываемый
// RawMessage: копия одна, живёт до следующего члена, удерживается ноль. Разбор
// `data` в map[string]json.RawMessage дешевле по коду, но копирует байты всех
// секций и держит их до конца разбора — этот тест сторожит разницу.
func TestParseУдержаниеКучиНепокрытойСекции(t *testing.T) {
if testing.Short() {
t.Skip("измерение кучи: не для -short")
}
body := bodyWithUncovered(40 << 20)
t.Logf("тело %d МиБ, из них непокрытая секция почти всё", len(body)>>20)
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
// Слой единственной точке взяться неоткуда — наследуем, иначе разбор
// откажет раньше, чем дойдёт до замера.
res, err := hae.Parse(body, hae.Meta{FallbackLayer: hae.LayerMinute})
if err != nil {
t.Fatalf("разбор: %v", err)
}
runtime.GC()
runtime.ReadMemStats(&after)
retained := int64(after.HeapAlloc) - int64(before.HeapAlloc)
limit := int64(len(body)) * 4
t.Logf("непокрытых %v, удержано %d МиБ при теле %d МиБ",
res.Uncovered, retained>>20, len(body)>>20)
if retained > limit {
t.Errorf("удержано %d МиБ при теле %d МиБ — больше четырёх тел; "+
"похоже, секции удерживаются, а не проглатываются",
retained>>20, len(body)>>20)
}
runtime.KeepAlive(res)
runtime.KeepAlive(body)
}
// Тело из вложенных скобок обязано быть ОТВЕРГНУТО, а не съедено. Пропуск
// ручным счётом глубины по Token() этого не давал: делимитеры идут мимо
// сканера, ограничитель вложенности encoding/json не работает, и стек токенов
// растёт как O(глубины) — измерено 488 МиБ пика на теле 40 МиБ.
func TestParseВложенныеСкобкиОтвергаются(t *testing.T) {
t.Parallel()
const depth = 100_000
var b strings.Builder
b.WriteString(`{"data":{"deep":`)
b.WriteString(strings.Repeat("[", depth))
b.WriteString(strings.Repeat("]", depth))
b.WriteString(`}}`)
if _, err := hae.Parse([]byte(b.String()), hae.Meta{}); err == nil {
t.Error("тело из ста тысяч уровней вложенности принято — ограничитель вложенности не работает")
}
}
func bodyWithUncovered(size int) []byte {
var b strings.Builder
b.WriteString(`{"data":{"metrics":[{"name":"m","units":"count","data":[`)
b.WriteString(`{"date":"2025-06-05 10:00:00 +0300","qty":1}`)
b.WriteString(`]}],"stateOfMind":[`)
const entry = `{"id":"00000000-0000-0000-0000-000000000000","kind":"momentary_emotion","valence":0.5},`
for b.Len() < size {
b.WriteString(entry)
}
b.WriteString(`{"id":"tail"}]}}`)
return []byte(b.String())
}
+49
View File
@@ -0,0 +1,49 @@
{
"data": {
"metrics": [
{
"name": "step_count",
"units": "count",
"data": [
{"date": "2025-06-05 09:00:00 +0300", "qty": 41.0, "source": "Device A"},
{"date": "2025-06-05 09:01:00 +0300", "qty": 17.0, "source": "Device A"},
{"date": "2025-06-05 09:02:00 +0300", "qty": 82.0, "source": "Device A"},
{"date": "2025-06-05 09:03:00 +0300", "qty": 5.0, "source": "Device A"},
{"date": "2025-06-05 09:04:00 +0300", "qty": 63.0, "source": "Device A"},
{"date": "2025-06-05 09:05:00 +0300", "qty": 28.0, "source": "Device A"},
{"date": "2025-06-05 09:06:00 +0300", "qty": 94.0, "source": "Device A"},
{"date": "2025-06-05 09:07:00 +0300", "qty": 12.0, "source": "Device A"},
{"date": "2025-06-05 09:08:00 +0300", "qty": 71.0, "source": "Device A"},
{"date": "2025-06-05 09:09:00 +0300", "qty": 36.0, "source": "Device A"}
]
}
],
"workouts": [
{
"id": "00000000-0000-0000-0000-000000000001",
"name": "В помещении Ходьба",
"start": "2025-06-05 08:00:00 +0300",
"end": "2025-06-05 08:30:00 +0300",
"duration": 1800,
"heartRateData": [
{"date": "2025-06-05 08:00:07 +0300", "Min": 91.0, "Avg": 94.5, "Max": 98.0, "units": "count/min"},
{"date": "2025-06-05 08:00:21 +0300", "Min": 93.0, "Avg": 95.5, "Max": 99.0, "units": "count/min"}
],
"route": [
{"lat": 10.0, "lon": 20.0, "altitude": 30.0, "timestamp": "2025-06-05 08:00:07 +0300"}
]
}
],
"stateOfMind": [
{
"id": "00000000-0000-0000-0000-000000000002",
"start": "2025-06-05T05:12:33Z",
"end": "2025-06-05T05:12:33Z",
"kind": "momentary_emotion",
"valence": 0.25,
"labels": ["slightly_pleasant"],
"associations": []
}
]
}
}
+46 -7
View File
@@ -3,6 +3,7 @@ package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
@@ -11,10 +12,18 @@ import (
// Статусы разбора доставки. Код ответа приёма от них не зависит: сохранили —
// значит приняли.
const (
// ParsePending — тело сохранено, разбора ещё не было.
// ParsePending — этим разбором тело ещё не смотрели.
//
// Смысл именно такой, а не «тела ещё не касались»: миграция 00005 перевела
// сюда доставки, разобранные кодом, который не различал частичный разбор.
// Статус консервативный — ретеншен не трогает pending никогда.
ParsePending = "pending"
// ParseDone — тело разобрано, точки разложены по объектам.
// ParseDone — разобрано всё, что в теле было.
ParseDone = "parsed"
// ParsePartial — разобрано покрытое, но в теле остались секции, которых
// разбор не покрывает. Не отклонение, а установившееся состояние половины
// потока: 48 доставок из 99 несут только workouts или stateOfMind.
ParsePartial = "partial"
// ParseFailed — разобрать не удалось. Тело лежит в архиве, доставку
// подберёт пересборка.
ParseFailed = "failed"
@@ -38,6 +47,10 @@ type Delivery struct {
// значений), уже без секретов. Именованные поля выше дублируют часть из
// них: по ним ходят запросы, а Headers хранит всё остальное на будущее.
Headers string
// UncoveredSections — секции тела, которых разбор не покрыл, JSON-массивом
// имён. Ответ на вопрос «что останется потерянным, если тело удалить»:
// ретеншен обязан спрашивать его прежде, чем срезать тело.
UncoveredSections string
}
// CreateDelivery записывает факт приёма пакета.
@@ -69,7 +82,7 @@ func (s *Store) LastDelivery(ctx context.Context) (Delivery, error) {
const q = `
SELECT id, received_at, automation_name, automation_id, aggregation,
period, session_id, bytes, sha256, raw_path, parse_status,
points, headers
points, headers, uncovered_sections
FROM delivery ORDER BY received_at DESC, id DESC LIMIT 1`
var d Delivery
@@ -77,7 +90,7 @@ func (s *Store) LastDelivery(ctx context.Context) (Delivery, error) {
err := s.db.QueryRowxContext(ctx, q).Scan(
&d.ID, &receivedAt, &d.AutomationName, &d.AutomationID, &d.Aggregation,
&d.Period, &d.SessionID, &d.Bytes, &d.SHA256, &d.RawPath,
&d.ParseStatus, &d.Points, &d.Headers)
&d.ParseStatus, &d.Points, &d.Headers, &d.UncoveredSections)
if errors.Is(err, sql.ErrNoRows) {
return Delivery{}, ErrNotFound
}
@@ -101,14 +114,40 @@ func (s *Store) LastDelivery(ctx context.Context) (Delivery, error) {
// Пустой layer означает «не трогать»: у неудачной свёртки слоя нет, а
// затирание оборвало бы цепочку наследования — в том числе у доставки, которая
// раньше свернулась успешно, — и результат пересборки журнала изменился бы.
func (s *Store) FinishParse(ctx context.Context, id, status string, points int64, layer string) error {
// ParseOutcome — исход разбора доставки. Структурой, а не растущим списком
// позиционных параметров: у FinishParse их было уже четыре, и пятый неизбежно
// перепутали бы местами с четвёртым.
type ParseOutcome struct {
Status string
Points int64
// Layer пустой означает «не трогать» — см. FinishParse.
Layer string
// Uncovered замещает прежнее значение ЦЕЛИКОМ, включая замещение пустым:
// у слоя пустота — отсутствие знания, у списка — знание об отсутствии.
// Пересвёртка доставки, чья секция стала покрытой, обязана список очистить.
Uncovered []string
}
func (s *Store) FinishParse(ctx context.Context, id string, out ParseOutcome) error {
const q = `
UPDATE delivery
SET parse_status = ?, points = ?,
derived_layer = CASE WHEN ? = '' THEN derived_layer ELSE ? END
derived_layer = CASE WHEN ? = '' THEN derived_layer ELSE ? END,
uncovered_sections = ?
WHERE id = ?`
res, err := s.db.ExecContext(ctx, q, status, points, layer, layer, id)
// Ровно одно представление пустоты — `[]`: nil-срез Go сериализуется как
// null, и в колонке появилось бы второе значение с тем же смыслом.
sections := out.Uncovered
if sections == nil {
sections = []string{}
}
encoded, err := json.Marshal(sections)
if err != nil {
return fmt.Errorf("encode uncovered sections: %w", err)
}
res, err := s.db.ExecContext(ctx, q, out.Status, out.Points, out.Layer, out.Layer, string(encoded), id)
if err != nil {
return fmt.Errorf("update parse status: %w", err)
}
+90
View File
@@ -0,0 +1,90 @@
package store
import (
"context"
"io/fs"
"path/filepath"
"testing"
"github.com/jmoiron/sqlx"
"github.com/pressly/goose/v3"
)
// Миграция 00005 переводит существующие `parsed` в `pending`. Статус, который
// поставил код, не различавший частичного разбора, ничего не доказывает: под
// ним лежат и полностью разобранные доставки, и доставки из одних тренировок с
// нулём точек (48 из 99 на живом архиве). Ретеншен, ради которого признак и
// заводится, поверил бы им и срезал тела — а для stateOfMind это необратимо.
func TestMigrationПрежниеParsedСтановятсяPending(t *testing.T) {
db, err := sqlx.Connect("sqlite", dsn(filepath.Join(t.TempDir(), "healthlog.db")))
if err != nil {
t.Fatalf("открытие базы: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
sub, err := fs.Sub(migrationsFS, "migrations")
if err != nil {
t.Fatalf("миграции: %v", err)
}
p, err := goose.NewProvider(goose.DialectSQLite3, db.DB, sub)
if err != nil {
t.Fatalf("провайдер: %v", err)
}
ctx := context.Background()
// Состояние ДО этой миграции: схема четвёртой версии.
if _, err := p.UpTo(ctx, 4); err != nil {
t.Fatalf("миграция до 4: %v", err)
}
const insert = `
INSERT INTO delivery (id, received_at, automation_name, automation_id,
aggregation, period, session_id, bytes, sha256, raw_path,
parse_status, points)
VALUES (?, '2025-06-05T10:00:00Z', '', '', '', '', '', 0, '-', '-', ?, ?)`
for _, c := range []struct {
id string
status string
points int
}{
{"d-parsed-points", "parsed", 12},
{"d-parsed-empty", "parsed", 0},
{"d-failed", "failed", 0},
{"d-pending", "pending", 0},
} {
if _, err := db.ExecContext(ctx, insert, c.id, c.status, c.points); err != nil {
t.Fatalf("вставка %s: %v", c.id, err)
}
}
if _, err := p.Up(ctx); err != nil {
t.Fatalf("миграция до последней: %v", err)
}
want := map[string]string{
// Переводятся ОБА варианта parsed, а не только пустой: наблюдение
// «секции не смешиваются» собрано за двое суток потока, и ставить на
// него необратимое удаление тел значит повторять ошибку, ради которой
// задача и заведена.
"d-parsed-points": "pending",
"d-parsed-empty": "pending",
"d-failed": "failed",
"d-pending": "pending",
}
for id, wantStatus := range want {
var status, sections string
err := db.QueryRowContext(ctx,
`SELECT parse_status, uncovered_sections FROM delivery WHERE id = ?`, id).
Scan(&status, &sections)
if err != nil {
t.Fatalf("чтение %s: %v", id, err)
}
if status != wantStatus {
t.Errorf("%s: статус %q, ожидался %q", id, status, wantStatus)
}
if sections != "[]" {
t.Errorf("%s: список %q, ожидался `[]`", id, sections)
}
}
}
@@ -0,0 +1,35 @@
-- +goose Up
-- Секции тела, которых разбор не покрыл. JSON-массив имён (`["stateOfMind"]`),
-- пустой список — `[]`.
--
-- Массивом, а не строкой с разделителем: имя ключа приходит из чужого тела и
-- может содержать что угодно, включая пробел и запятую. JSON снимает вопрос
-- разделителя, согласуется с колонкой headers и читается из SQLite через
-- json_each, если ретеншену это понадобится.
--
-- Колонка — ответ на вопрос «что останется потерянным, если тело удалить».
-- Для stateOfMind ответ необратим: в экспорте Apple его нет (находка 46),
-- доставки HAE для него единственный источник.
ALTER TABLE delivery ADD COLUMN uncovered_sections TEXT NOT NULL DEFAULT '[]';
-- Статус parsed, поставленный кодом, который частичного разбора не различал,
-- ничего не доказывает: под ним лежат и полностью разобранные доставки, и
-- доставки из одних тренировок с нулём точек (48 из 99 на живом архиве).
-- Ретеншен, ради которого признак и заводится, поверил бы им и срезал тела.
--
-- pending — консервативный статус: ретеншен не трогает его никогда, а подбор
-- pending пересвернёт доставки из архива. Свёртка идемпотентна, повторный
-- прогон журнала состояния не меняет.
--
-- Целевой перевод только строк с points = 0 рассматривался и отвергнут: он
-- опирается на наблюдение «секции не смешиваются», собранное за двое суток
-- потока, а ставить на такое наблюдение необратимое удаление тел значит
-- повторять ошибку, ради которой задача и заведена.
UPDATE delivery SET parse_status = 'pending' WHERE parse_status = 'parsed';
-- +goose Down
-- ВНИМАНИЕ: миграция односторонняя ПО ДАННЫМ. Down снимает колонку, но какие
-- доставки были parsed, восстановить неоткуда — состояние пересобирается из
-- архива, а не откатом. До появления подбора pending строки останутся в этом
-- статусе.
ALTER TABLE delivery DROP COLUMN uncovered_sections;
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-01
@@ -0,0 +1,277 @@
## Context
Тело доставки HAE — это `{"data": {…секции…}}`. Разбор сегодня знает ровно одну
секцию, `metrics`; всё остальное (`workouts`, `stateOfMind`, `symptoms`, `ecg`,
`cycleTracking`, `medications`, `heartRateNotifications`) проходит мимо молча, и
доставка получает `parse_status=parsed` с нулём точек.
Замер на живом архиве (99 доставок, 17 МБ сжатыми) показал, чем это стоит:
| набор верхнеуровневых ключей `data` | доставок |
|---|---|
| `metrics` | 51 |
| `workouts` | 24 |
| `stateOfMind` | 24 |
Три наблюдения, каждое из которых влияет на решение:
1. **Половина потока — не `metrics`.** 48 доставок из 99 сейчас числятся
разобранными, не будучи разобранными.
2. **Секции не смешиваются.** Ни одна доставка не несла двух секций сразу —
автоматизация HAE шлёт одну секцию за раз.
3. **Пустых секций не бывает.** Все 99 значений непусты; HAE не отправляет
пустой пакет вовсе (находка 18).
Ограничения, в которые обязано вписаться решение:
- Тела доходят до 42 МиБ. Стратегия декодирования — часть контракта, её
сторожит `TestParseУдержаниеКучи`: удержано не больше четырёх тел.
- Разбор — чистая функция от тела и заголовков, без обращений к хранилищу.
- Данные о здоровье чувствительнее токенов: в логе допустимы имена ключей,
но не содержимое секций.
## Goals / Non-Goals
**Goals:**
- Доставка, содержащая непокрытую разбором секцию, отличима от доставки,
разобранной целиком, — и в учёте, и в логе.
- Список непокрытых ключей сохранён рядом с доставкой: он же ответ на вопрос
«что останется потерянным, если тело удалить».
- Перечисление не удерживает содержимого секций и не ломает контракт удержания
кучи.
**Non-Goals:**
- Разбор самих секций (`workouts`, `stateOfMind` и прочие) — отдельные задачи.
- Ретеншен сырого архива. Здесь готовится признак, на который он обопрётся.
- Ключи **верхнего** уровня тела помимо `data`. Форма `{"data": …}` проверяется
приёмом, других ключей в потоке не наблюдалось; перечислять их значило бы
смешать в одном списке имена секций и мусор конверта.
- Подбор доставок в статусе `pending` — задача `otvet-i-svyortka`.
## Decisions
### 1. Перечисление — в том же проходе, что и разбор метрик
`decodeMetrics` превращается в `decodeEnvelope`: один `json.Decoder` идёт по
верхнему уровню тела, доходит до объекта `data` и разбирает его члены по
одному. Имя члена читается `Token()`, значение покрытого ключа декодируется на
месте в существующую форму (`[]metricEnvelope` с точками как
`json.RawMessage`), значение непокрытого — **проглатывается** декодированием в
выбрасываемый `json.RawMessage`.
Это форма из `ExampleDecoder_Decode_stream` стандартной библиотеки: `Token()`
для рамки объекта и имён, `Decode()` для значений. В `encoding/json/v2` та же
операция названа прямо — `jsontext.Decoder.SkipValue`.
Альтернативы и чем плохи:
- **Пропуск ручным счётом глубины по `Token()`.** Выглядит дешевле — и
измеримо хуже: делимитеры идут мимо сканера, поэтому ограничитель вложенности
`encoding/json` (10 000 уровней) не работает, а стек токенов растёт как
O(глубины). Измерено: тело 40 МиБ из вложенных скобок даёт пик кучи 488 МиБ
(12 тел вместо контрактных четырёх), тогда как `Decode` отвергает его
мгновенно. На настоящей секции 27 МиБ счёт глубины стоит 331 МиБ мусора и
689 мс против 91 МиБ и 190 мс у `Decode`.
- **Второй проход по телу.** Перечисление стало бы независимым от разбора, но
42 МиБ прошли бы через токенизатор дважды — вся секция `metrics` во второй
раз впустую.
- **`Data map[string]json.RawMessage`.** Три строки кода, но `RawMessage`
копирует байты **всех** секций и держит их до конца разбора. У проглатывания
копия одна, живёт до следующего члена и удерживается ноль.
- **Свой сканер по байтам тела.** Не нужно: `encoding/json` умеет всё нужное, а
собственный сканер JSON — это экранирование строк, суррогатные пары и вечный
источник расхождений.
Граница утверждения: речь о пике внутри `hae.Parse`. Приём отдельно держит свою
копию `data` (`ingest.checkEnvelope`), и на пик процесса влияет она же —
перечисление этого не меняет.
### 2. Непокрытый — значит не разобранный, а не «пустой»
Ключ попадает в список, если разбор его **не покрывает**, независимо от того,
что внутри. Содержимое не удерживается — значит и о пустоте секции мы честно
ничего не знаем.
Соблазн «пустую секцию не считать» существует: он снял бы шум, если бы HAE слал
`"workouts": []` в каждой доставке. Замер говорит, что не слал ни разу.
Покрытая секция сегодня ровно одна — `metrics`. Покрытость выражена **функцией**
рядом с разбором, а не изменяемой пакетной картой: разбор и перечисление ходят
по одному источнику, состояние «секция разбирается, но числится непокрытой»
невыразимо.
Список **канонизируется перед выдачей**: сортировка по имени и удаление
повторов. Порядок ключей в JSON от HAE нестабилен (находка 30 и вся история
канонизации содержимого), а значение уезжает в базу и сравнивается между
доставками; список, зависящий от порядка на проводе, сравнивать нельзя.
### 3. Статус `partial` — исход разбора, а не третий вид отказа
```
pending этим разбором ещё не смотрели
parsed разобрано всё, что в теле было
partial разобрано покрытое; в теле остались непокрытые секции
failed разобрать не удалось, точек нет
```
Источник истины — **список**; статус производен от него и от факта отказа:
```
failed ← разбор вернул ошибку (сильнее всего)
partial ← список непуст
parsed ← иначе
```
Приоритет назван явно, потому что иначе два будущих читателя (ретеншен,
`/stats`) разойдутся: один спросит `parse_status`, другой —
`uncovered_sections != '[]'`. Спрашивать полагается статус; список отвечает на
вопрос «что именно осталось».
Список сохраняется и при отказе, если разбор успел его собрать: доставка
`metrics` + `stateOfMind`, у которой не определился слой, обязана остаться
записью о том, что в теле есть невосстановимая секция. Поэтому `failed` с
непустым списком — законное состояние, а не противоречие.
`CHECK` на колонке нет (конвенция: допустимые значения держит код), поэтому
новый статус миграции сам по себе не требует.
### 4. Список непокрытых ключей хранится JSON-массивом
Колонка `delivery.uncovered_sections TEXT NOT NULL DEFAULT '[]'`, значение —
JSON-массив имён (`["stateOfMind"]`), пустой список — `[]`.
Почему массивом, а не строкой с разделителем: имя ключа приходит из чужого
тела и может содержать что угодно, включая пробел и запятую. JSON снимает
вопрос разделителя, согласуется с колонкой `headers` и читается из SQLite через
`json_each`, если ретеншену это понадобится.
Ровно одно представление пустоты — `[]`. `nil`-срез в Go сериализуется как
`null`, поэтому нормализация делается на границе `store` тем же приёмом, каким
там уже нормализуются `headers` (пустое → `{}`).
Запись **замещает** прежнее значение целиком, включая замещение пустым. Это
отличается от `derived_layer`, где пустая строка означает «не трогать»:
у слоя пустота — отсутствие знания, у списка — знание об отсутствии.
### 5. Границы на список: 32 ключа, 64 байта на имя, и обе обрезки видны
Тело контролирует отправитель целиком. Без границ тело из ста тысяч
однобуквенных ключей превращается в одну строку в базе и одну строку в логе
того же порядка. Поэтому:
- не больше 32 имён; число отброшенных сверх лимита идёт **счётчиком**
(`UncoveredDropped`) в исход разбора и атрибутом лога — иначе «ровно 32
секции» неотличимо от «пришло пятьсот», а список ровно для того и заведён,
чтобы отвечать на вопрос о полноте;
- имя длиннее 64 байт обрезается по границе рун, к обрезанному имени
приписывается маркер `…`; маркер **сверх** предела, а не внутри него;
- граница считается по байтам **декодированного** имени: `Token()` отдаёт имя
уже после разбора escape-последовательностей.
Числа выбраны с запасом: секций у HAE восемь, самое длинное имя —
`heartRateNotifications` (22 байта). Обрезка не инъективна (два длинных ключа
могут дать одно имя), поэтому она и помечается — обрезанное имя сравнению со
словарём известных секций не подлежит.
Срабатывание любой из границ — событие уровня `WARN`: это не частичный разбор,
а тело, не похожее на HAE.
### 6. Уровень лога от одной лишь частичности не растёт
Непокрытые ключи добавляются атрибутом `uncovered` в единственный логирующий
чекпоинт свёртки — там, где уже живут `layer`, `sealed_hits` и прочие признаки.
`WARN` на самой частичности был бы неверен: `partial` — не отклонение, а
установившееся состояние половины потока (48 доставок из 99). Постоянный `WARN`
каждые пять минут обесценивает уровень ровно так же, как обесценило бы
сравнение с заголовком `Default`. Момент появления **новой** секции — отдельная
задача (`proverka-novyh-sekcij`), и она будет опираться на сохранённый список.
Имена идут структурным атрибутом (`[]string`), а не склейкой в строку: JSON-
кодировщик `slog` экранирует управляющие символы, поэтому имя из чужого тела не
разрывает построчный разбор логов. Содержимого секций в записи нет ни на каком
уровне выше `DEBUG`.
### 7. Отказ разбора — всё или ничего, как и раньше
Разбор стал потоковым, и ошибка может встретиться **после** того, как `metrics`
уже разобрана (обрезанное тело, мусор в следующем члене). Правило прежнее:
`Parse` при ошибке точек не отдаёт, свёртка ничего не сливает и пишет `failed`.
Иначе свёртка перестала бы быть детерминированной по журналу: часть точек
оказалась бы в витрине под статусом, по которому доставку никто не подберёт.
Повтор ключа `metrics` (JSON это допускает) даёт **объединение** секций, а не
победу последней: терять точки молча нельзя. Повтор непокрытого ключа даёт одно
имя в списке — список канонизирован.
### 8. Строки, свёрнутые прежним кодом, переводятся в `pending`
Статус `parsed`, поставленный кодом, который частичного разбора не различал,
ничего не доказывает: под ним лежат и полностью разобранные доставки, и
`workouts`-доставки с нулём точек. Ретеншен, ради которого признак и заводится,
поверил бы им и срезал тела.
Поэтому миграция переводит существующие `parsed` в `pending` — «этим разбором
ещё не смотрели». Это консервативный статус: ретеншен не трогает `pending`
никогда, а подбор `pending` (задача `otvet-i-svyortka`) пересвернёт доставки из
архива. Свёртка идемпотентна, повторный прогон журнала состояния не меняет —
проверено `task verify:archive`.
Рассматривался целевой перевод только строк с `points = 0` (те самые 48). Он
опирается на наблюдение «секции не смешиваются», собранное за двое суток
потока, — а ставить на такое наблюдение необратимое удаление тел значит
повторять ошибку, ради которой задача и заведена.
Следствия, названные вслух:
- `pending` теперь означает «этим разбором ещё не смотрели», а не «тела ещё не
касались». Док-комментарий константы и `docs/database.md` правятся тем же
изменением.
- `points` у переведённых строк остаётся прежним до пересвёртки: он производен
от объектов витрины, которые никуда не делись.
- Миграция **односторонняя по данным**: `Down` снимает колонку, но какие
доставки были `parsed`, восстановить неоткуда. Цена нулевая — состояние
пересобирается из архива, — но откат перестаёт быть операцией «вернулись и
работаем»: до появления подбора `pending` строки останутся в этом статусе.
- Порядок задач: пока `otvet-i-svyortka` не сделана, 99 доставок числятся
`pending` и никем не подбираются. Приём и свёртка новых доставок при этом
работают как раньше.
### 9. Правило для будущих задач: покрыли секцию — пересверните
Список — снимок покрытия **на момент свёртки**. Доставки, свёрнутые до того,
как секция стала покрытой, останутся `partial` со старым списком, и ретеншен
будет вечно щадить тела, которые уже не нужны.
Поэтому конвенция, вводимая этим изменением: задача, которая начинает разбирать
секцию, тем же изменением переводит `partial`-строки с этим ключом в `pending`
— тем же приёмом, что и миграция здесь. Ретеншену позволено смотреть на
`partial` только при соблюдении этого правила.
### 10. Фикстура рукотворная — и это осознанно
Конвенция требует тестов на реальных пакетах, но здесь проверяется конверт, а
не содержимое: секция не читается вовсе, поэтому реальность её содержимого
ничего не доказывает. Реальный пакет `stateOfMind` под контроль версий не
попадёт никогда — это измерения состояния разума, а санитайзер `fixtures.py`
написан под метрики. Живой поток покрывается прогоном `task verify:archive`,
который проходит по всем 99 доставкам архива.
## Risks / Trade-offs
- **Проглатывание непокрытой секции копирует её байты** → копия одна, живёт до
следующего члена, удерживается ноль; пик внутри `Parse` — тело плюс
наибольшая непокрытая секция, то есть вдвое меньше контрактного предела.
- **99 доставок разом станут `pending`** → до появления подбора `pending` они
останутся в этом статусе. Данные не теряются: тела в архиве, объекты в
витрине, а `pending` безопаснее ложного `parsed`.
- **Обрезка длинного имени искажает его** → маркер делает обрезку видимой,
счётчик отброшенных — неполноту списка; оба идут в лог `WARN`.
- **`partial` — новое значение в колонке без `CHECK`** → значения держит код,
как и для остальных статусов; тест на запись и чтение статуса закрывает
опечатку.
- **Регресс в переписанном пути к `metrics`** → сверка витрины, собранной из
живого архива, со снимком, снятым до изменения: совпадение по объектам,
точкам и метрикам, а не только сходимость нового кода с самим собой.
@@ -0,0 +1,58 @@
## Why
Разбор читает только `data.metrics`. Доставка, целиком состоящая из другой
секции, получает `parse_status=parsed` с нулём точек — неотличимо от доставки с
пустой секцией метрик. Измерено на живом архиве: из 99 доставок 48 не содержат
`metrics` вовсе (24 `workouts`, 24 `stateOfMind`), то есть почти половина потока
сейчас числится разобранной, не будучи разобранной.
Само по себе это некритично — тела лежат в архиве. Опасность в сцепке с
ретеншеном: он по замыслу срезает архив до следующего проверенного экспорта, и
если станет опираться на `parse_status`, снесёт тела, которые числятся
разобранными. Для `stateOfMind` это необратимо — в экспорте Apple его нет
(находка 46), доставки HAE единственный его источник. Ретеншен — следующая
задача, поэтому признак нужен до неё.
## What Changes
- `hae.Parse` перечисляет верхнеуровневые ключи `data` и возвращает те, что
разбор не покрыл. Перечисление идёт **без чтения содержимого секций**: тела
доходят до 42 МиБ, и удержание кучи здесь — часть контракта.
- Появляется статус доставки `partial` рядом с `parsed`/`failed`: тело
разобрано в той части, которую разбор покрывает, и в нём остались
непокрытые секции.
- Свёртка сохраняет список непокрытых ключей в исход доставки и называет их
**именами ключей** в своём единственном логирующем чекпоинте. Содержимого
секций в логе нет и быть не может.
- Число ключей и длина имени в записи ограничены: ключи приходят из тела,
которым отправитель управляет целиком; усечение видно счётчиком и маркером.
- **Миграция переписывает `parse_status` у всех накопленных доставок:**
существующие `parsed` становятся `pending` — «этим разбором ещё не смотрели».
Статус, поставленный кодом, который частичного разбора не различал, ничего не
доказывает, а ретеншен собирается на него опираться. Цена: 99 строк живой базы
меняют статус на первом старте нового бинаря, и до появления подбора `pending`
(задача `otvet-i-svyortka`) никто их не пересвернёт; тела при этом остаются в
архиве, объекты витрины — на месте, и `Down` прежние статусы не восстановит.
## Capabilities
### New Capabilities
Новых нет.
### Modified Capabilities
- `parsing`: разбор обязан перечислять непокрытые верхнеуровневые ключи `data`,
не читая их содержимого, и отдавать их вызывающему.
- `storage`: у доставки появляется статус `partial` и список непокрытых секций;
учёт обязан отличать «разобрано целиком» от «разобрано частично».
## Impact
- `internal/hae` — перечисление ключей в том же проходе, что и разбор метрик.
- `internal/store` — константа статуса, колонка `uncovered_sections`, миграция.
- `internal/fold` — исход свёртки, статус и атрибут лога.
- `docs/database.md`, `docs/architecture.md`, `docs/local-research.md`
схема, статусы и находка о наборах секций в живом потоке.
- Ретеншен сырого архива (задача `retenshen-syrogo-arhiva`) получает признак,
на который ему можно опираться.
@@ -0,0 +1,104 @@
## ADDED Requirements
### Requirement: Перечисление непокрытых секций доставки
Разбор SHALL перечислять верхнеуровневые ключи объекта `data` и возвращать
вызывающему те из них, которые он не покрывает. Содержимое непокрытой секции
MUST NOT удерживаться после того, как разбор прошёл мимо неё: тела доходят до
42 МиБ, и удержание кучи здесь — часть контракта, а не деталь реализации.
Покрытым сегодня является ровно один ключ — `metrics`. Разбор и перечисление
MUST ходить по одному объявленному множеству покрытых имён: состояние «секция
разбирается, но числится непокрытой» невыразимо по построению.
Непокрытым ключ считается независимо от того, что лежит внутри: содержимое не
интерпретируется, поэтому и о пустоте секции разбор честно ничего не знает.
Измерено на живом архиве — пустых секций HAE не присылает ни разу (99 доставок).
Список SHALL быть каноничен: имена отсортированы, повторов нет. Порядок ключей в
JSON от HAE нестабилен, а значение уезжает в базу и сравнивается между
доставками.
Отсутствие непокрытых ключей и отсутствие секции `metrics` — разные события, и
оба нормальны: половина потока состоит из доставок без метрик вовсе (48 из 99).
#### Scenario: Незнакомая секция попадает в список непокрытых
- **WHEN** тело содержит `data.workouts` наряду с `data.metrics`
- **THEN** разбор возвращает `workouts` в списке непокрытых ключей
- **AND** точки секции `metrics` разбираются как обычно
#### Scenario: Доставка без метрик разбирается и не теряется
- **WHEN** тело содержит только `data.stateOfMind`
- **THEN** разбор завершается без ошибки, точек нет
- **AND** `stateOfMind` возвращается в списке непокрытых ключей
#### Scenario: Доставка из одних метрик непокрытых ключей не даёт
- **WHEN** единственный ключ `data``metrics`
- **THEN** список непокрытых ключей пуст
#### Scenario: Один и тот же набор секций даёт один и тот же список
- **WHEN** два тела несут те же секции в разном порядке, а одно из них
повторяет непокрытый ключ дважды
- **THEN** списки непокрытых ключей у них совпадают
#### Scenario: Содержимое непокрытой секции не удерживается в памяти
- **WHEN** тело в десятки мегабайт состоит преимущественно из непокрытой секции
- **THEN** после разбора удержано не больше четырёх размеров тела — та же
граница, что и для тела из метрик
- **AND** содержимое непокрытой секции в результат разбора не попадает
### Requirement: Границы списка непокрытых секций
Список непокрытых ключей MUST быть ограничен — не больше 32 имён и не больше
64 байт на имя: имена приходят из тела, которым отправитель управляет целиком.
Срабатывание любой из границ MUST быть видно вызывающему — молчаливое усечение
превратило бы список в уверенный, но неполный ответ на вопрос «что останется
потерянным, если тело удалить».
Число имён сверх предела отдаётся счётчиком. Имя длиннее предела обрезается по
границе рун, к обрезанному приписывается маркер `…` — сверх предела, а не внутри
него. Обрезка не инъективна, поэтому обрезанное имя сравнению со словарём
известных секций не подлежит.
Предел длины считается по байтам **декодированного** имени: escape-
последовательности JSON к этому моменту уже разобраны.
#### Scenario: Ключей больше предела
- **WHEN** объект `data` содержит 40 непокрытых ключей
- **THEN** список содержит 32 имени
- **AND** число отброшенных имён отдано отдельным счётчиком
#### Scenario: Имя ключа длиннее предела
- **WHEN** непокрытый ключ длиннее 64 байт
- **THEN** в списке лежит имя, обрезанное по границе рун, с маркером `…`
### Requirement: Отказ разбора остаётся всё или ничего
Разбор SHALL оставаться операцией «всё или ничего»: ошибка, встреченная
**после** того, как секция `metrics` уже разобрана (обрезанное тело, мусор в
следующем члене), MUST NOT оставлять точки в результате — доставка считается
неразобранной целиком.
Иначе часть точек оказалась бы в витрине под статусом, по которому доставку
никто не подберёт, и свёртка перестала бы быть детерминированной по журналу.
Повтор ключа `metrics` в одном объекте `data` SHALL давать объединение секций, а
не победу последней: молча терять точки нельзя.
#### Scenario: Тело оборвано после секции метрик
- **WHEN** тело содержит целую секцию `metrics`, а следующий член `data`
оборван
- **THEN** разбор завершается ошибкой и точек не отдаёт
#### Scenario: Секция метрик встречается дважды
- **WHEN** объект `data` содержит два ключа `metrics`
- **THEN** точки обеих секций попадают в результат
@@ -0,0 +1,114 @@
## ADDED Requirements
### Requirement: Учёт частично разобранной доставки
Система SHALL отличать доставку, разобранную целиком, от доставки, в теле
которой остались непокрытые разбором секции. Доставка с непустым списком
непокрытых ключей MUST получать статус `partial`, а не `parsed`.
Статусы разбора:
```
pending этим разбором ещё не смотрели
parsed разобрано всё, что в теле было
partial разобрано покрытое; в теле остались непокрытые секции
failed разобрать не удалось, точек нет
```
Источник истины — список непокрытых ключей; статус производен от него и от
факта отказа, в порядке `failed``partial``parsed`. Приоритет назван явно,
чтобы читатели (ретеншен, статистика) спрашивали статус, а не сравнивали список
со строкой.
Список непокрытых ключей SHALL сохраняться рядом с доставкой — именами ключей,
без содержимого секций. Он же ответ на вопрос «что останется потерянным, если
тело удалить»: для `stateOfMind` доставки HAE единственный источник, в экспорте
Apple его нет (находка 46). Поэтому список MUST сохраняться и при отказе
разбора, если разбор успел его собрать: `failed` с непустым списком — законное
состояние.
Запись списка MUST замещать прежнее значение целиком, включая замещение пустым:
иначе доставка, все секции которой стали покрытыми, осталась бы `partial`
навсегда.
Список — снимок покрытия **на момент свёртки**. Задача, которая начинает
разбирать секцию, тем же изменением SHALL переводить `partial`-строки с этим
ключом в `pending`; ретеншену позволено смотреть на `partial` только при
соблюдении этого правила.
Статусы, поставленные разбором, который частичного исхода не различал, доверия
не заслуживают: под `parsed` у них лежат и полностью разобранные доставки, и
доставки без метрик вовсе. Такие строки MUST переводиться в `pending` — «этим
разбором ещё не смотрели». Число точек у них до пересвёртки остаётся прежним: оно
производно от объектов витрины, которые никуда не делись.
#### Scenario: Доставка с непокрытой секцией отмечается частичной
- **WHEN** разбор доставки вернул непустой список непокрытых ключей
- **THEN** `parse_status` доставки равен `partial`
- **AND** список непокрытых ключей сохранён вместе с доставкой
- **AND** точки покрытой секции сохранены как обычно
#### Scenario: Доставка без непокрытых секций остаётся `parsed`
- **WHEN** разбор доставки не дал непокрытых ключей
- **THEN** `parse_status` равен `parsed`
- **AND** сохранённый список непокрытых ключей пуст
#### Scenario: Отказ разбора сильнее частичности
- **WHEN** разбор доставки завершился ошибкой
- **THEN** `parse_status` равен `failed`
- **AND** список непокрытых ключей сохранён, если разбор успел его собрать
#### Scenario: Пересвёртка после того, как секция стала покрытой
- **WHEN** доставка со статусом `partial` сворачивается повторно разбором,
который эту секцию уже покрывает
- **THEN** её статус становится `parsed`
- **AND** сохранённый список непокрытых ключей пуст
#### Scenario: Строки прежнего разбора переводятся в неразобранные
- **WHEN** база содержит доставки со статусом `parsed`, свёрнутые до появления
частичного статуса
- **THEN** после миграции их статус равен `pending`
- **AND** тела остаются в архиве, а повторная свёртка даёт то же состояние
## MODIFIED Requirements
### Requirement: Значения точек не попадают в логи
Данные о здоровье чувствительнее токенов. Система MUST NOT писать значения
точек и тела доставок в записи лога уровня выше `DEBUG`.
Непокрытые секции называются в логе **именами ключей**: имя секции — это форма
пакета, а не измерение. Содержимое секции в лог не попадает ни при каком уровне
выше `DEBUG`. Имена идут структурным атрибутом, а не склейкой в текст сообщения:
кодировщик экранирует управляющие символы, и имя из чужого тела не разрывает
построчный разбор логов.
Частичный разбор уровня записи не повышает: `partial` — установившееся состояние
половины потока (48 доставок из 99), и постоянный `WARN` обесценил бы уровень.
Повышает уровень другое — срабатывание границ списка: тело с сотнями секций или
с именем длиннее предела на HAE не похоже вовсе.
#### Scenario: Разбор доставки логируется без значений
- **WHEN** доставка разобрана
- **THEN** запись лога содержит счётчики (метрик, точек, объектов) и
идентификатор доставки
- **AND** не содержит ни значений точек, ни имён устройств
#### Scenario: Непокрытые секции названы именами ключей
- **WHEN** доставка содержит непокрытую секцию
- **THEN** запись лога содержит имена непокрытых ключей отдельным атрибутом
- **AND** не содержит ничего из содержимого этих секций
- **AND** уровень записи из-за одной лишь частичности не повышается
#### Scenario: Границы списка сработали
- **WHEN** список непокрытых ключей усечён по числу имён или по длине имени
- **THEN** запись лога имеет уровень `WARN`
- **AND** содержит число отброшенных имён
@@ -0,0 +1,84 @@
## 1. Разбор: перечисление непокрытых ключей
- [x] 1.1 `decodeMetrics``decodeEnvelope`: один `json.Decoder` идёт по
верхнему уровню, имя члена читается `Token()`, значение покрытого ключа
декодируется на месте, значение непокрытого проглатывается декодированием в
выбрасываемый `json.RawMessage` (ограничитель вложенности stdlib при этом
работает, в отличие от ручного счёта глубины)
- [x] 1.2 Покрытость — функция рядом с разбором, а не изменяемая пакетная карта;
`Result.Uncovered` отдаёт непокрытые отсортированными и без повторов
- [x] 1.3 Границы: не больше 32 имён (`Result.UncoveredDropped` считает
отброшенные), имя длиннее 64 байт декодированного имени обрезано по границе
рун, маркер `…` приписывается сверх предела
- [x] 1.4 Повтор ключа `metrics` даёт объединение секций; повтор непокрытого
ключа даёт одно имя. Тело без `data`, `data` не объект, `data` пустой,
`metrics` неверного типа — прежнее поведение (ошибка ровно там, где была)
- [x] 1.5 Ошибка после разобранной секции `metrics` точек не отдаёт
## 2. Хранилище: статус и список
- [x] 2.1 Константа `store.ParsePartial`; док-комментарий `ParsePending`
переписан на «этим разбором ещё не смотрели»
- [x] 2.2 Миграция: колонка `uncovered_sections TEXT NOT NULL DEFAULT '[]'` и
перевод существующих `parsed` в `pending`; в комментарии миграции сказано, что
по данным она односторонняя — `Down` снимает колонку, прежние статусы не
восстанавливает
- [x] 2.3 Исход разбора пишется структурой (`store.ParseOutcome`), а не растущим
списком позиционных параметров; список замещает прежнее значение целиком,
включая замещение пустым; `nil` и пустой срез записываются как `[]`
## 3. Свёртка: исход и лог
- [x] 3.1 `Stats.Uncovered` и `Stats.UncoveredDropped`; статус `partial` при
непустом списке, `failed` сильнее; при отказе список сохраняется, если разбор
успел его собрать
- [x] 3.2 Атрибуты `uncovered` (структурным `[]string`) и `uncovered_dropped` в
единственном логирующем чекпоинте; уровень из-за одной лишь частичности не
растёт, срабатывание границ даёт `WARN`
## 4. Тесты (приёмочные критерии)
- [x] 4.1 Фикстура `uncovered_sections.json`: точки метрик сохранены, ключи
`workouts` и `stateOfMind` в списке, статус `partial`
- [x] 4.2 Доставка из одной непокрытой секции: разбор без ошибки, ноль точек,
ключ в списке, статус `partial`
- [x] 4.3 Доставка из одних метрик: список пуст, статус `parsed`
- [x] 4.4 Детерминизм: тот же набор секций в разном порядке и с повтором ключа
даёт тот же список
- [x] 4.5 Границы: 40 ключей → 32 имени и счётчик отброшенных; длинное имя →
обрезка с маркером
- [x] 4.6 Удержание кучи: тело в десятки мегабайт, состоящее преимущественно из
непокрытой секции, удерживает не больше четырёх тел; тело из вложенных скобок
отвергается, а не съедает память
- [x] 4.7 Отказ всё или ничего: тело оборвано после секции метрик — ошибка, ноль
точек, `failed`
- [x] 4.8 Лог свёртки: имена ключей есть, содержимого секций нет; уровень при
обычной частичности не повышен
- [x] 4.9 Миграция: строка со статусом `parsed` становится `pending`, колонка
получает `[]`
- [x] 4.10 Пересвёртка: доставка `partial`, у которой список опустел, становится
`parsed` с пустым списком
- [x] 4.11 Прогон живого архива (`task verify:archive`): доставки без метрик
получают `partial` с непустым списком, повторный прогон состояния не меняет
- [x] 4.12 Сверка с состоянием ДО изменения: витрина, собранная из живого архива
новым кодом, совпадает по объектам, точкам и метрикам со снимком, снятым до
изменения
## 5. Документация
- [x] 5.1 `docs/database.md`: колонка, полный набор статусов, новый смысл
`pending`
- [x] 5.2 `docs/architecture.md`: частичный разбор в разделе приёма
- [x] 5.3 `docs/local-research.md`: находка о наборах секций в живом потоке
(99 доставок: 51 `metrics`, 24 `workouts`, 24 `stateOfMind`, секции не
смешиваются, пустых нет)
## 6. Замеры после реализации
- [x] 6.1 Живой архив (104 доставки): частично разобрано **50** — 25 `stateOfMind`
и 25 `workouts`. Отпечаток витрины `a59b38ea…` совпал с прогоном ДО изменения
на том же архиве: переписанный разбор конверта — строгий no-op для витрины
- [x] 6.2 Удержание кучи: тело 40 МиБ, из которых почти всё — непокрытая
секция, удерживает **0 МиБ**; тело из 100 000 уровней вложенности отвергается
- [x] 6.3 Живой сервис разобрал пришедшие с телефона доставки (5 штук) —
заодно закрыт пункт 6.3a задачи `razbor-metrik-v-obekty`
+103
View File
@@ -258,3 +258,106 @@ Export шлёт под одним именем, чтобы одно имя оз
- **THEN** ответ на приём остаётся `200`
- **AND** исход виден в `delivery.parse_status` и в записи лога
### Requirement: Перечисление непокрытых секций доставки
Разбор SHALL перечислять верхнеуровневые ключи объекта `data` и возвращать
вызывающему те из них, которые он не покрывает. Содержимое непокрытой секции
MUST NOT удерживаться после того, как разбор прошёл мимо неё: тела доходят до
42 МиБ, и удержание кучи здесь — часть контракта, а не деталь реализации.
Покрытым сегодня является ровно один ключ — `metrics`. Разбор и перечисление
MUST ходить по одному объявленному множеству покрытых имён: состояние «секция
разбирается, но числится непокрытой» невыразимо по построению.
Непокрытым ключ считается независимо от того, что лежит внутри: содержимое не
интерпретируется, поэтому и о пустоте секции разбор честно ничего не знает.
Измерено на живом архиве — пустых секций HAE не присылает ни разу (99 доставок).
Список SHALL быть каноничен: имена отсортированы, повторов нет. Порядок ключей в
JSON от HAE нестабилен, а значение уезжает в базу и сравнивается между
доставками.
Отсутствие непокрытых ключей и отсутствие секции `metrics` — разные события, и
оба нормальны: половина потока состоит из доставок без метрик вовсе (48 из 99).
#### Scenario: Незнакомая секция попадает в список непокрытых
- **WHEN** тело содержит `data.workouts` наряду с `data.metrics`
- **THEN** разбор возвращает `workouts` в списке непокрытых ключей
- **AND** точки секции `metrics` разбираются как обычно
#### Scenario: Доставка без метрик разбирается и не теряется
- **WHEN** тело содержит только `data.stateOfMind`
- **THEN** разбор завершается без ошибки, точек нет
- **AND** `stateOfMind` возвращается в списке непокрытых ключей
#### Scenario: Доставка из одних метрик непокрытых ключей не даёт
- **WHEN** единственный ключ `data``metrics`
- **THEN** список непокрытых ключей пуст
#### Scenario: Один и тот же набор секций даёт один и тот же список
- **WHEN** два тела несут те же секции в разном порядке, а одно из них
повторяет непокрытый ключ дважды
- **THEN** списки непокрытых ключей у них совпадают
#### Scenario: Содержимое непокрытой секции не удерживается в памяти
- **WHEN** тело в десятки мегабайт состоит преимущественно из непокрытой секции
- **THEN** после разбора удержано не больше четырёх размеров тела — та же
граница, что и для тела из метрик
- **AND** содержимое непокрытой секции в результат разбора не попадает
### Requirement: Границы списка непокрытых секций
Список непокрытых ключей MUST быть ограничен — не больше 32 имён и не больше
64 байт на имя: имена приходят из тела, которым отправитель управляет целиком.
Срабатывание любой из границ MUST быть видно вызывающему — молчаливое усечение
превратило бы список в уверенный, но неполный ответ на вопрос «что останется
потерянным, если тело удалить».
Число имён сверх предела отдаётся счётчиком. Имя длиннее предела обрезается по
границе рун, к обрезанному приписывается маркер `…` — сверх предела, а не внутри
него. Обрезка не инъективна, поэтому обрезанное имя сравнению со словарём
известных секций не подлежит.
Предел длины считается по байтам **декодированного** имени: escape-
последовательности JSON к этому моменту уже разобраны.
#### Scenario: Ключей больше предела
- **WHEN** объект `data` содержит 40 непокрытых ключей
- **THEN** список содержит 32 имени
- **AND** число отброшенных имён отдано отдельным счётчиком
#### Scenario: Имя ключа длиннее предела
- **WHEN** непокрытый ключ длиннее 64 байт
- **THEN** в списке лежит имя, обрезанное по границе рун, с маркером `…`
### Requirement: Отказ разбора остаётся всё или ничего
Разбор SHALL оставаться операцией «всё или ничего»: ошибка, встреченная
**после** того, как секция `metrics` уже разобрана (обрезанное тело, мусор в
следующем члене), MUST NOT оставлять точки в результате — доставка считается
неразобранной целиком.
Иначе часть точек оказалась бы в витрине под статусом, по которому доставку
никто не подберёт, и свёртка перестала бы быть детерминированной по журналу.
Повтор ключа `metrics` в одном объекте `data` SHALL давать объединение секций, а
не победу последней: молча терять точки нельзя.
#### Scenario: Тело оборвано после секции метрик
- **WHEN** тело содержит целую секцию `metrics`, а следующий член `data`
оборван
- **THEN** разбор завершается ошибкой и точек не отдаёт
#### Scenario: Секция метрик встречается дважды
- **WHEN** объект `data` содержит два ключа `metrics`
- **THEN** точки обеих секций попадают в результат
+99
View File
@@ -303,6 +303,17 @@ HTML-экранирования: `&`, `<` и `>` внутри точки обя
Данные о здоровье чувствительнее токенов. Система MUST NOT писать значения
точек и тела доставок в записи лога уровня выше `DEBUG`.
Непокрытые секции называются в логе **именами ключей**: имя секции — это форма
пакета, а не измерение. Содержимое секции в лог не попадает ни при каком уровне
выше `DEBUG`. Имена идут структурным атрибутом, а не склейкой в текст сообщения:
кодировщик экранирует управляющие символы, и имя из чужого тела не разрывает
построчный разбор логов.
Частичный разбор уровня записи не повышает: `partial` — установившееся состояние
половины потока (48 доставок из 99), и постоянный `WARN` обесценил бы уровень.
Повышает уровень другое — срабатывание границ списка: тело с сотнями секций или
с именем длиннее предела на HAE не похоже вовсе.
#### Scenario: Разбор доставки логируется без значений
- **WHEN** доставка разобрана
@@ -310,3 +321,91 @@ HTML-экранирования: `&`, `<` и `>` внутри точки обя
идентификатор доставки
- **AND** не содержит ни значений точек, ни имён устройств
#### Scenario: Непокрытые секции названы именами ключей
- **WHEN** доставка содержит непокрытую секцию
- **THEN** запись лога содержит имена непокрытых ключей отдельным атрибутом
- **AND** не содержит ничего из содержимого этих секций
- **AND** уровень записи из-за одной лишь частичности не повышается
#### Scenario: Границы списка сработали
- **WHEN** список непокрытых ключей усечён по числу имён или по длине имени
- **THEN** запись лога имеет уровень `WARN`
- **AND** содержит число отброшенных имён
### Requirement: Учёт частично разобранной доставки
Система SHALL отличать доставку, разобранную целиком, от доставки, в теле
которой остались непокрытые разбором секции. Доставка с непустым списком
непокрытых ключей MUST получать статус `partial`, а не `parsed`.
Статусы разбора:
```
pending этим разбором ещё не смотрели
parsed разобрано всё, что в теле было
partial разобрано покрытое; в теле остались непокрытые секции
failed разобрать не удалось, точек нет
```
Источник истины — список непокрытых ключей; статус производен от него и от
факта отказа, в порядке `failed``partial``parsed`. Приоритет назван явно,
чтобы читатели (ретеншен, статистика) спрашивали статус, а не сравнивали список
со строкой.
Список непокрытых ключей SHALL сохраняться рядом с доставкой — именами ключей,
без содержимого секций. Он же ответ на вопрос «что останется потерянным, если
тело удалить»: для `stateOfMind` доставки HAE единственный источник, в экспорте
Apple его нет (находка 46). Поэтому список MUST сохраняться и при отказе
разбора, если разбор успел его собрать: `failed` с непустым списком — законное
состояние.
Запись списка MUST замещать прежнее значение целиком, включая замещение пустым:
иначе доставка, все секции которой стали покрытыми, осталась бы `partial`
навсегда.
Список — снимок покрытия **на момент свёртки**. Задача, которая начинает
разбирать секцию, тем же изменением SHALL переводить `partial`-строки с этим
ключом в `pending`; ретеншену позволено смотреть на `partial` только при
соблюдении этого правила.
Статусы, поставленные разбором, который частичного исхода не различал, доверия
не заслуживают: под `parsed` у них лежат и полностью разобранные доставки, и
доставки без метрик вовсе. Такие строки MUST переводиться в `pending` — «этим
разбором ещё не смотрели». Число точек у них до пересвёртки остаётся прежним: оно
производно от объектов витрины, которые никуда не делись.
#### Scenario: Доставка с непокрытой секцией отмечается частичной
- **WHEN** разбор доставки вернул непустой список непокрытых ключей
- **THEN** `parse_status` доставки равен `partial`
- **AND** список непокрытых ключей сохранён вместе с доставкой
- **AND** точки покрытой секции сохранены как обычно
#### Scenario: Доставка без непокрытых секций остаётся `parsed`
- **WHEN** разбор доставки не дал непокрытых ключей
- **THEN** `parse_status` равен `parsed`
- **AND** сохранённый список непокрытых ключей пуст
#### Scenario: Отказ разбора сильнее частичности
- **WHEN** разбор доставки завершился ошибкой
- **THEN** `parse_status` равен `failed`
- **AND** список непокрытых ключей сохранён, если разбор успел его собрать
#### Scenario: Пересвёртка после того, как секция стала покрытой
- **WHEN** доставка со статусом `partial` сворачивается повторно разбором,
который эту секцию уже покрывает
- **THEN** её статус становится `parsed`
- **AND** сохранённый список непокрытых ключей пуст
#### Scenario: Строки прежнего разбора переводятся в неразобранные
- **WHEN** база содержит доставки со статусом `parsed`, свёрнутые до появления
частичного статуса
- **THEN** после миграции их статус равен `pending`
- **AND** тела остаются в архиве, а повторная свёртка даёт то же состояние