diff --git a/docs/architecture.md b/docs/architecture.md index 25f31e3..fac0971 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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` ограничивает то, что приехало по сети, а в память попадает то, что из этого развернулось. diff --git a/docs/backlog/README.md b/docs/backlog/README.md index 553de41..ffe0418 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -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 ## средний diff --git a/docs/backlog/nerazobrannye-sekcii-dostavki.md b/docs/backlog/nerazobrannye-sekcii-dostavki.md deleted file mode 100644 index c6441cc..0000000 --- a/docs/backlog/nerazobrannye-sekcii-dostavki.md +++ /dev/null @@ -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) — по мере разбора секций список - непокрытых сокращается сам. diff --git a/docs/backlog/proverka-novyh-sekcij.md b/docs/backlog/proverka-novyh-sekcij.md index 22b8eaa..edb08ce 100644 --- a/docs/backlog/proverka-novyh-sekcij.md +++ b/docs/backlog/proverka-novyh-sekcij.md @@ -29,3 +29,10 @@ `docs/local-research.md` как не пришедшая, и ни одна не числится в ошибках разбора. +## Что уже сделано + +Разбор перечисляет непокрытые секции и пишет их в `delivery.uncovered_sections` +(change `2026-08-01-nerazobrannye-sekcii-dostavki`). Момент, когда поток принесёт +секцию, которой раньше не было, теперь **фиксируется** — остаётся научиться +замечать его активно: один `SELECT DISTINCT` по колонке даёт список всего, что +поток приносил, и сравнение с известным набором закрывает задачу. diff --git a/docs/backlog/retenshen-syrogo-arhiva.md b/docs/backlog/retenshen-syrogo-arhiva.md index 5d1b72c..45c7e0a 100644 --- a/docs/backlog/retenshen-syrogo-arhiva.md +++ b/docs/backlog/retenshen-syrogo-arhiva.md @@ -26,3 +26,14 @@ экспорта, записи `stateOfMind` не трогаются вовсе, а `/stats` показывает глубину архива и дату снапшота, до которой он подрезан. +## Предусловие снято + +Признак, без которого ретеншен был опасен, готов: доставка с непокрытой секцией +имеет статус `partial` и список непокрытых ключей +(change `2026-08-01-nerazobrannye-sekcii-dostavki`). Ретеншен обязан спрашивать +статус, а не считать `parsed` разрешением: тело `stateOfMind` восстановить +неоткуда — в экспорте Apple секции нет. + +Вместе с этим действует правило: задача, которая начинает разбирать секцию, тем +же изменением переводит `partial`-строки с этим ключом в `pending`. Ретеншену +позволено смотреть на `partial` только пока правило соблюдается. diff --git a/docs/database.md b/docs/database.md index 547e575..7bf6828 100644 --- a/docs/database.md +++ b/docs/database.md @@ -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` (учёт diff --git a/docs/local-research.md b/docs/local-research.md index b327eb9..d1d77af 100644 --- a/docs/local-research.md +++ b/docs/local-research.md @@ -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, только стандартная diff --git a/internal/fold/fold.go b/internal/fold/fold.go index 3b4f922..85fe714 100644 --- a/internal/fold/fold.go +++ b/internal/fold/fold.go @@ -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) } } diff --git a/internal/fold/fold_test.go b/internal/fold/fold_test.go index 5bf36d1..8a4f0ba 100644 --- a/internal/fold/fold_test.go +++ b/internal/fold/fold_test.go @@ -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) + } +} diff --git a/internal/fold/log_test.go b/internal/fold/log_test.go index e354638..ecaab03 100644 --- a/internal/fold/log_test.go +++ b/internal/fold/log_test.go @@ -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) + } +} diff --git a/internal/fold/replay_test.go b/internal/fold/replay_test.go index 006d222..c433114 100644 --- a/internal/fold/replay_test.go +++ b/internal/fold/replay_test.go @@ -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("ни одной частично разобранной доставки — перечисление непокрытых секций не работает") + } + // Повторный прогон того же журнала не меняет состояния: свёртка // детерминирована, и пересборка даёт то же, что живой приём. // diff --git a/internal/hae/hae.go b/internal/hae/hae.go index f86400c..407ab3a 100644 --- a/internal/hae/hae.go +++ b/internal/hae/hae.go @@ -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 разбирает точки одной метрики. Точка без разбираемой метки diff --git a/internal/hae/hae_test.go b/internal/hae/hae_test.go index f690148..18f5273 100644 --- a/internal/hae/hae_test.go +++ b/internal/hae/hae_test.go @@ -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)) + } + }) +} diff --git a/internal/hae/mem_test.go b/internal/hae/mem_test.go index f113d0d..50afd0e 100644 --- a/internal/hae/mem_test.go +++ b/internal/hae/mem_test.go @@ -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()) +} diff --git a/internal/hae/testdata/uncovered_sections.json b/internal/hae/testdata/uncovered_sections.json new file mode 100644 index 0000000..4c96a15 --- /dev/null +++ b/internal/hae/testdata/uncovered_sections.json @@ -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": [] + } + ] + } +} diff --git a/internal/store/delivery.go b/internal/store/delivery.go index b0c47f8..aba127c 100644 --- a/internal/store/delivery.go +++ b/internal/store/delivery.go @@ -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) } diff --git a/internal/store/migration_test.go b/internal/store/migration_test.go new file mode 100644 index 0000000..b7b687f --- /dev/null +++ b/internal/store/migration_test.go @@ -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, §ions) + 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) + } + } +} diff --git a/internal/store/migrations/00005_delivery_uncovered.sql b/internal/store/migrations/00005_delivery_uncovered.sql new file mode 100644 index 0000000..a084525 --- /dev/null +++ b/internal/store/migrations/00005_delivery_uncovered.sql @@ -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; diff --git a/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/.openspec.yaml b/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/.openspec.yaml new file mode 100644 index 0000000..5849c2d --- /dev/null +++ b/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-01 diff --git a/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/design.md b/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/design.md new file mode 100644 index 0000000..53320fa --- /dev/null +++ b/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/design.md @@ -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`** → сверка витрины, собранной из + живого архива, со снимком, снятым до изменения: совпадение по объектам, + точкам и метрикам, а не только сходимость нового кода с самим собой. diff --git a/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/proposal.md b/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/proposal.md new file mode 100644 index 0000000..3c9084f --- /dev/null +++ b/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/proposal.md @@ -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`) получает признак, + на который ему можно опираться. diff --git a/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/specs/parsing/spec.md b/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/specs/parsing/spec.md new file mode 100644 index 0000000..65b4978 --- /dev/null +++ b/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/specs/parsing/spec.md @@ -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** точки обеих секций попадают в результат diff --git a/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/specs/storage/spec.md b/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/specs/storage/spec.md new file mode 100644 index 0000000..30e9824 --- /dev/null +++ b/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/specs/storage/spec.md @@ -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** содержит число отброшенных имён diff --git a/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/tasks.md b/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/tasks.md new file mode 100644 index 0000000..e60d5b6 --- /dev/null +++ b/openspec/changes/archive/2026-08-01-nerazobrannye-sekcii-dostavki/tasks.md @@ -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` diff --git a/openspec/specs/parsing/spec.md b/openspec/specs/parsing/spec.md index 78e1b5f..6f536ba 100644 --- a/openspec/specs/parsing/spec.md +++ b/openspec/specs/parsing/spec.md @@ -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** точки обеих секций попадают в результат + diff --git a/openspec/specs/storage/spec.md b/openspec/specs/storage/spec.md index 020c4fd..f29695a 100644 --- a/openspec/specs/storage/spec.md +++ b/openspec/specs/storage/spec.md @@ -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** тела остаются в архиве, а повторная свёртка даёт то же состояние +