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

- половина потока (50 доставок из 104) не несёт metrics вовсе и до сих пор
  числилась parsed: ретеншен, поверив статусу, срезал бы тела stateOfMind,
  которых в экспорте Apple нет
- разбор перечисляет верхнеуровневые ключи data, непокрытые проглатываются
  декодированием: тело 40 МиБ из непокрытой секции удерживает 0 МиБ
- статус partial и колонка delivery.uncovered_sections; миграция переводит
  прежние parsed в pending — им верить нельзя
- витрина не изменилась: отпечаток совпал с прогоном до изменения
This commit is contained in:
av
2026-08-01 21:25:59 +03:00
parent 7a7594e3e7
commit 34e5109b6d
26 changed files with 1808 additions and 89 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-01
@@ -0,0 +1,277 @@
## Context
Тело доставки HAE — это `{"data": {…секции…}}`. Разбор сегодня знает ровно одну
секцию, `metrics`; всё остальное (`workouts`, `stateOfMind`, `symptoms`, `ecg`,
`cycleTracking`, `medications`, `heartRateNotifications`) проходит мимо молча, и
доставка получает `parse_status=parsed` с нулём точек.
Замер на живом архиве (99 доставок, 17 МБ сжатыми) показал, чем это стоит:
| набор верхнеуровневых ключей `data` | доставок |
|---|---|
| `metrics` | 51 |
| `workouts` | 24 |
| `stateOfMind` | 24 |
Три наблюдения, каждое из которых влияет на решение:
1. **Половина потока — не `metrics`.** 48 доставок из 99 сейчас числятся
разобранными, не будучи разобранными.
2. **Секции не смешиваются.** Ни одна доставка не несла двух секций сразу —
автоматизация HAE шлёт одну секцию за раз.
3. **Пустых секций не бывает.** Все 99 значений непусты; HAE не отправляет
пустой пакет вовсе (находка 18).
Ограничения, в которые обязано вписаться решение:
- Тела доходят до 42 МиБ. Стратегия декодирования — часть контракта, её
сторожит `TestParseУдержаниеКучи`: удержано не больше четырёх тел.
- Разбор — чистая функция от тела и заголовков, без обращений к хранилищу.
- Данные о здоровье чувствительнее токенов: в логе допустимы имена ключей,
но не содержимое секций.
## Goals / Non-Goals
**Goals:**
- Доставка, содержащая непокрытую разбором секцию, отличима от доставки,
разобранной целиком, — и в учёте, и в логе.
- Список непокрытых ключей сохранён рядом с доставкой: он же ответ на вопрос
«что останется потерянным, если тело удалить».
- Перечисление не удерживает содержимого секций и не ломает контракт удержания
кучи.
**Non-Goals:**
- Разбор самих секций (`workouts`, `stateOfMind` и прочие) — отдельные задачи.
- Ретеншен сырого архива. Здесь готовится признак, на который он обопрётся.
- Ключи **верхнего** уровня тела помимо `data`. Форма `{"data": …}` проверяется
приёмом, других ключей в потоке не наблюдалось; перечислять их значило бы
смешать в одном списке имена секций и мусор конверта.
- Подбор доставок в статусе `pending` — задача `otvet-i-svyortka`.
## Decisions
### 1. Перечисление — в том же проходе, что и разбор метрик
`decodeMetrics` превращается в `decodeEnvelope`: один `json.Decoder` идёт по
верхнему уровню тела, доходит до объекта `data` и разбирает его члены по
одному. Имя члена читается `Token()`, значение покрытого ключа декодируется на
месте в существующую форму (`[]metricEnvelope` с точками как
`json.RawMessage`), значение непокрытого — **проглатывается** декодированием в
выбрасываемый `json.RawMessage`.
Это форма из `ExampleDecoder_Decode_stream` стандартной библиотеки: `Token()`
для рамки объекта и имён, `Decode()` для значений. В `encoding/json/v2` та же
операция названа прямо — `jsontext.Decoder.SkipValue`.
Альтернативы и чем плохи:
- **Пропуск ручным счётом глубины по `Token()`.** Выглядит дешевле — и
измеримо хуже: делимитеры идут мимо сканера, поэтому ограничитель вложенности
`encoding/json` (10 000 уровней) не работает, а стек токенов растёт как
O(глубины). Измерено: тело 40 МиБ из вложенных скобок даёт пик кучи 488 МиБ
(12 тел вместо контрактных четырёх), тогда как `Decode` отвергает его
мгновенно. На настоящей секции 27 МиБ счёт глубины стоит 331 МиБ мусора и
689 мс против 91 МиБ и 190 мс у `Decode`.
- **Второй проход по телу.** Перечисление стало бы независимым от разбора, но
42 МиБ прошли бы через токенизатор дважды — вся секция `metrics` во второй
раз впустую.
- **`Data map[string]json.RawMessage`.** Три строки кода, но `RawMessage`
копирует байты **всех** секций и держит их до конца разбора. У проглатывания
копия одна, живёт до следующего члена и удерживается ноль.
- **Свой сканер по байтам тела.** Не нужно: `encoding/json` умеет всё нужное, а
собственный сканер JSON — это экранирование строк, суррогатные пары и вечный
источник расхождений.
Граница утверждения: речь о пике внутри `hae.Parse`. Приём отдельно держит свою
копию `data` (`ingest.checkEnvelope`), и на пик процесса влияет она же —
перечисление этого не меняет.
### 2. Непокрытый — значит не разобранный, а не «пустой»
Ключ попадает в список, если разбор его **не покрывает**, независимо от того,
что внутри. Содержимое не удерживается — значит и о пустоте секции мы честно
ничего не знаем.
Соблазн «пустую секцию не считать» существует: он снял бы шум, если бы HAE слал
`"workouts": []` в каждой доставке. Замер говорит, что не слал ни разу.
Покрытая секция сегодня ровно одна — `metrics`. Покрытость выражена **функцией**
рядом с разбором, а не изменяемой пакетной картой: разбор и перечисление ходят
по одному источнику, состояние «секция разбирается, но числится непокрытой»
невыразимо.
Список **канонизируется перед выдачей**: сортировка по имени и удаление
повторов. Порядок ключей в JSON от HAE нестабилен (находка 30 и вся история
канонизации содержимого), а значение уезжает в базу и сравнивается между
доставками; список, зависящий от порядка на проводе, сравнивать нельзя.
### 3. Статус `partial` — исход разбора, а не третий вид отказа
```
pending этим разбором ещё не смотрели
parsed разобрано всё, что в теле было
partial разобрано покрытое; в теле остались непокрытые секции
failed разобрать не удалось, точек нет
```
Источник истины — **список**; статус производен от него и от факта отказа:
```
failed ← разбор вернул ошибку (сильнее всего)
partial ← список непуст
parsed ← иначе
```
Приоритет назван явно, потому что иначе два будущих читателя (ретеншен,
`/stats`) разойдутся: один спросит `parse_status`, другой —
`uncovered_sections != '[]'`. Спрашивать полагается статус; список отвечает на
вопрос «что именно осталось».
Список сохраняется и при отказе, если разбор успел его собрать: доставка
`metrics` + `stateOfMind`, у которой не определился слой, обязана остаться
записью о том, что в теле есть невосстановимая секция. Поэтому `failed` с
непустым списком — законное состояние, а не противоречие.
`CHECK` на колонке нет (конвенция: допустимые значения держит код), поэтому
новый статус миграции сам по себе не требует.
### 4. Список непокрытых ключей хранится JSON-массивом
Колонка `delivery.uncovered_sections TEXT NOT NULL DEFAULT '[]'`, значение —
JSON-массив имён (`["stateOfMind"]`), пустой список — `[]`.
Почему массивом, а не строкой с разделителем: имя ключа приходит из чужого
тела и может содержать что угодно, включая пробел и запятую. JSON снимает
вопрос разделителя, согласуется с колонкой `headers` и читается из SQLite через
`json_each`, если ретеншену это понадобится.
Ровно одно представление пустоты — `[]`. `nil`-срез в Go сериализуется как
`null`, поэтому нормализация делается на границе `store` тем же приёмом, каким
там уже нормализуются `headers` (пустое → `{}`).
Запись **замещает** прежнее значение целиком, включая замещение пустым. Это
отличается от `derived_layer`, где пустая строка означает «не трогать»:
у слоя пустота — отсутствие знания, у списка — знание об отсутствии.
### 5. Границы на список: 32 ключа, 64 байта на имя, и обе обрезки видны
Тело контролирует отправитель целиком. Без границ тело из ста тысяч
однобуквенных ключей превращается в одну строку в базе и одну строку в логе
того же порядка. Поэтому:
- не больше 32 имён; число отброшенных сверх лимита идёт **счётчиком**
(`UncoveredDropped`) в исход разбора и атрибутом лога — иначе «ровно 32
секции» неотличимо от «пришло пятьсот», а список ровно для того и заведён,
чтобы отвечать на вопрос о полноте;
- имя длиннее 64 байт обрезается по границе рун, к обрезанному имени
приписывается маркер `…`; маркер **сверх** предела, а не внутри него;
- граница считается по байтам **декодированного** имени: `Token()` отдаёт имя
уже после разбора escape-последовательностей.
Числа выбраны с запасом: секций у HAE восемь, самое длинное имя —
`heartRateNotifications` (22 байта). Обрезка не инъективна (два длинных ключа
могут дать одно имя), поэтому она и помечается — обрезанное имя сравнению со
словарём известных секций не подлежит.
Срабатывание любой из границ — событие уровня `WARN`: это не частичный разбор,
а тело, не похожее на HAE.
### 6. Уровень лога от одной лишь частичности не растёт
Непокрытые ключи добавляются атрибутом `uncovered` в единственный логирующий
чекпоинт свёртки — там, где уже живут `layer`, `sealed_hits` и прочие признаки.
`WARN` на самой частичности был бы неверен: `partial` — не отклонение, а
установившееся состояние половины потока (48 доставок из 99). Постоянный `WARN`
каждые пять минут обесценивает уровень ровно так же, как обесценило бы
сравнение с заголовком `Default`. Момент появления **новой** секции — отдельная
задача (`proverka-novyh-sekcij`), и она будет опираться на сохранённый список.
Имена идут структурным атрибутом (`[]string`), а не склейкой в строку: JSON-
кодировщик `slog` экранирует управляющие символы, поэтому имя из чужого тела не
разрывает построчный разбор логов. Содержимого секций в записи нет ни на каком
уровне выше `DEBUG`.
### 7. Отказ разбора — всё или ничего, как и раньше
Разбор стал потоковым, и ошибка может встретиться **после** того, как `metrics`
уже разобрана (обрезанное тело, мусор в следующем члене). Правило прежнее:
`Parse` при ошибке точек не отдаёт, свёртка ничего не сливает и пишет `failed`.
Иначе свёртка перестала бы быть детерминированной по журналу: часть точек
оказалась бы в витрине под статусом, по которому доставку никто не подберёт.
Повтор ключа `metrics` (JSON это допускает) даёт **объединение** секций, а не
победу последней: терять точки молча нельзя. Повтор непокрытого ключа даёт одно
имя в списке — список канонизирован.
### 8. Строки, свёрнутые прежним кодом, переводятся в `pending`
Статус `parsed`, поставленный кодом, который частичного разбора не различал,
ничего не доказывает: под ним лежат и полностью разобранные доставки, и
`workouts`-доставки с нулём точек. Ретеншен, ради которого признак и заводится,
поверил бы им и срезал тела.
Поэтому миграция переводит существующие `parsed` в `pending` — «этим разбором
ещё не смотрели». Это консервативный статус: ретеншен не трогает `pending`
никогда, а подбор `pending` (задача `otvet-i-svyortka`) пересвернёт доставки из
архива. Свёртка идемпотентна, повторный прогон журнала состояния не меняет —
проверено `task verify:archive`.
Рассматривался целевой перевод только строк с `points = 0` (те самые 48). Он
опирается на наблюдение «секции не смешиваются», собранное за двое суток
потока, — а ставить на такое наблюдение необратимое удаление тел значит
повторять ошибку, ради которой задача и заведена.
Следствия, названные вслух:
- `pending` теперь означает «этим разбором ещё не смотрели», а не «тела ещё не
касались». Док-комментарий константы и `docs/database.md` правятся тем же
изменением.
- `points` у переведённых строк остаётся прежним до пересвёртки: он производен
от объектов витрины, которые никуда не делись.
- Миграция **односторонняя по данным**: `Down` снимает колонку, но какие
доставки были `parsed`, восстановить неоткуда. Цена нулевая — состояние
пересобирается из архива, — но откат перестаёт быть операцией «вернулись и
работаем»: до появления подбора `pending` строки останутся в этом статусе.
- Порядок задач: пока `otvet-i-svyortka` не сделана, 99 доставок числятся
`pending` и никем не подбираются. Приём и свёртка новых доставок при этом
работают как раньше.
### 9. Правило для будущих задач: покрыли секцию — пересверните
Список — снимок покрытия **на момент свёртки**. Доставки, свёрнутые до того,
как секция стала покрытой, останутся `partial` со старым списком, и ретеншен
будет вечно щадить тела, которые уже не нужны.
Поэтому конвенция, вводимая этим изменением: задача, которая начинает разбирать
секцию, тем же изменением переводит `partial`-строки с этим ключом в `pending`
— тем же приёмом, что и миграция здесь. Ретеншену позволено смотреть на
`partial` только при соблюдении этого правила.
### 10. Фикстура рукотворная — и это осознанно
Конвенция требует тестов на реальных пакетах, но здесь проверяется конверт, а
не содержимое: секция не читается вовсе, поэтому реальность её содержимого
ничего не доказывает. Реальный пакет `stateOfMind` под контроль версий не
попадёт никогда — это измерения состояния разума, а санитайзер `fixtures.py`
написан под метрики. Живой поток покрывается прогоном `task verify:archive`,
который проходит по всем 99 доставкам архива.
## Risks / Trade-offs
- **Проглатывание непокрытой секции копирует её байты** → копия одна, живёт до
следующего члена, удерживается ноль; пик внутри `Parse` — тело плюс
наибольшая непокрытая секция, то есть вдвое меньше контрактного предела.
- **99 доставок разом станут `pending`** → до появления подбора `pending` они
останутся в этом статусе. Данные не теряются: тела в архиве, объекты в
витрине, а `pending` безопаснее ложного `parsed`.
- **Обрезка длинного имени искажает его** → маркер делает обрезку видимой,
счётчик отброшенных — неполноту списка; оба идут в лог `WARN`.
- **`partial` — новое значение в колонке без `CHECK`** → значения держит код,
как и для остальных статусов; тест на запись и чтение статуса закрывает
опечатку.
- **Регресс в переписанном пути к `metrics`** → сверка витрины, собранной из
живого архива, со снимком, снятым до изменения: совпадение по объектам,
точкам и метрикам, а не только сходимость нового кода с самим собой.
@@ -0,0 +1,58 @@
## Why
Разбор читает только `data.metrics`. Доставка, целиком состоящая из другой
секции, получает `parse_status=parsed` с нулём точек — неотличимо от доставки с
пустой секцией метрик. Измерено на живом архиве: из 99 доставок 48 не содержат
`metrics` вовсе (24 `workouts`, 24 `stateOfMind`), то есть почти половина потока
сейчас числится разобранной, не будучи разобранной.
Само по себе это некритично — тела лежат в архиве. Опасность в сцепке с
ретеншеном: он по замыслу срезает архив до следующего проверенного экспорта, и
если станет опираться на `parse_status`, снесёт тела, которые числятся
разобранными. Для `stateOfMind` это необратимо — в экспорте Apple его нет
(находка 46), доставки HAE единственный его источник. Ретеншен — следующая
задача, поэтому признак нужен до неё.
## What Changes
- `hae.Parse` перечисляет верхнеуровневые ключи `data` и возвращает те, что
разбор не покрыл. Перечисление идёт **без чтения содержимого секций**: тела
доходят до 42 МиБ, и удержание кучи здесь — часть контракта.
- Появляется статус доставки `partial` рядом с `parsed`/`failed`: тело
разобрано в той части, которую разбор покрывает, и в нём остались
непокрытые секции.
- Свёртка сохраняет список непокрытых ключей в исход доставки и называет их
**именами ключей** в своём единственном логирующем чекпоинте. Содержимого
секций в логе нет и быть не может.
- Число ключей и длина имени в записи ограничены: ключи приходят из тела,
которым отправитель управляет целиком; усечение видно счётчиком и маркером.
- **Миграция переписывает `parse_status` у всех накопленных доставок:**
существующие `parsed` становятся `pending` — «этим разбором ещё не смотрели».
Статус, поставленный кодом, который частичного разбора не различал, ничего не
доказывает, а ретеншен собирается на него опираться. Цена: 99 строк живой базы
меняют статус на первом старте нового бинаря, и до появления подбора `pending`
(задача `otvet-i-svyortka`) никто их не пересвернёт; тела при этом остаются в
архиве, объекты витрины — на месте, и `Down` прежние статусы не восстановит.
## Capabilities
### New Capabilities
Новых нет.
### Modified Capabilities
- `parsing`: разбор обязан перечислять непокрытые верхнеуровневые ключи `data`,
не читая их содержимого, и отдавать их вызывающему.
- `storage`: у доставки появляется статус `partial` и список непокрытых секций;
учёт обязан отличать «разобрано целиком» от «разобрано частично».
## Impact
- `internal/hae` — перечисление ключей в том же проходе, что и разбор метрик.
- `internal/store` — константа статуса, колонка `uncovered_sections`, миграция.
- `internal/fold` — исход свёртки, статус и атрибут лога.
- `docs/database.md`, `docs/architecture.md`, `docs/local-research.md`
схема, статусы и находка о наборах секций в живом потоке.
- Ретеншен сырого архива (задача `retenshen-syrogo-arhiva`) получает признак,
на который ему можно опираться.
@@ -0,0 +1,104 @@
## ADDED Requirements
### Requirement: Перечисление непокрытых секций доставки
Разбор SHALL перечислять верхнеуровневые ключи объекта `data` и возвращать
вызывающему те из них, которые он не покрывает. Содержимое непокрытой секции
MUST NOT удерживаться после того, как разбор прошёл мимо неё: тела доходят до
42 МиБ, и удержание кучи здесь — часть контракта, а не деталь реализации.
Покрытым сегодня является ровно один ключ — `metrics`. Разбор и перечисление
MUST ходить по одному объявленному множеству покрытых имён: состояние «секция
разбирается, но числится непокрытой» невыразимо по построению.
Непокрытым ключ считается независимо от того, что лежит внутри: содержимое не
интерпретируется, поэтому и о пустоте секции разбор честно ничего не знает.
Измерено на живом архиве — пустых секций HAE не присылает ни разу (99 доставок).
Список SHALL быть каноничен: имена отсортированы, повторов нет. Порядок ключей в
JSON от HAE нестабилен, а значение уезжает в базу и сравнивается между
доставками.
Отсутствие непокрытых ключей и отсутствие секции `metrics` — разные события, и
оба нормальны: половина потока состоит из доставок без метрик вовсе (48 из 99).
#### Scenario: Незнакомая секция попадает в список непокрытых
- **WHEN** тело содержит `data.workouts` наряду с `data.metrics`
- **THEN** разбор возвращает `workouts` в списке непокрытых ключей
- **AND** точки секции `metrics` разбираются как обычно
#### Scenario: Доставка без метрик разбирается и не теряется
- **WHEN** тело содержит только `data.stateOfMind`
- **THEN** разбор завершается без ошибки, точек нет
- **AND** `stateOfMind` возвращается в списке непокрытых ключей
#### Scenario: Доставка из одних метрик непокрытых ключей не даёт
- **WHEN** единственный ключ `data``metrics`
- **THEN** список непокрытых ключей пуст
#### Scenario: Один и тот же набор секций даёт один и тот же список
- **WHEN** два тела несут те же секции в разном порядке, а одно из них
повторяет непокрытый ключ дважды
- **THEN** списки непокрытых ключей у них совпадают
#### Scenario: Содержимое непокрытой секции не удерживается в памяти
- **WHEN** тело в десятки мегабайт состоит преимущественно из непокрытой секции
- **THEN** после разбора удержано не больше четырёх размеров тела — та же
граница, что и для тела из метрик
- **AND** содержимое непокрытой секции в результат разбора не попадает
### Requirement: Границы списка непокрытых секций
Список непокрытых ключей MUST быть ограничен — не больше 32 имён и не больше
64 байт на имя: имена приходят из тела, которым отправитель управляет целиком.
Срабатывание любой из границ MUST быть видно вызывающему — молчаливое усечение
превратило бы список в уверенный, но неполный ответ на вопрос «что останется
потерянным, если тело удалить».
Число имён сверх предела отдаётся счётчиком. Имя длиннее предела обрезается по
границе рун, к обрезанному приписывается маркер `…` — сверх предела, а не внутри
него. Обрезка не инъективна, поэтому обрезанное имя сравнению со словарём
известных секций не подлежит.
Предел длины считается по байтам **декодированного** имени: escape-
последовательности JSON к этому моменту уже разобраны.
#### Scenario: Ключей больше предела
- **WHEN** объект `data` содержит 40 непокрытых ключей
- **THEN** список содержит 32 имени
- **AND** число отброшенных имён отдано отдельным счётчиком
#### Scenario: Имя ключа длиннее предела
- **WHEN** непокрытый ключ длиннее 64 байт
- **THEN** в списке лежит имя, обрезанное по границе рун, с маркером `…`
### Requirement: Отказ разбора остаётся всё или ничего
Разбор SHALL оставаться операцией «всё или ничего»: ошибка, встреченная
**после** того, как секция `metrics` уже разобрана (обрезанное тело, мусор в
следующем члене), MUST NOT оставлять точки в результате — доставка считается
неразобранной целиком.
Иначе часть точек оказалась бы в витрине под статусом, по которому доставку
никто не подберёт, и свёртка перестала бы быть детерминированной по журналу.
Повтор ключа `metrics` в одном объекте `data` SHALL давать объединение секций, а
не победу последней: молча терять точки нельзя.
#### Scenario: Тело оборвано после секции метрик
- **WHEN** тело содержит целую секцию `metrics`, а следующий член `data`
оборван
- **THEN** разбор завершается ошибкой и точек не отдаёт
#### Scenario: Секция метрик встречается дважды
- **WHEN** объект `data` содержит два ключа `metrics`
- **THEN** точки обеих секций попадают в результат
@@ -0,0 +1,114 @@
## ADDED Requirements
### Requirement: Учёт частично разобранной доставки
Система SHALL отличать доставку, разобранную целиком, от доставки, в теле
которой остались непокрытые разбором секции. Доставка с непустым списком
непокрытых ключей MUST получать статус `partial`, а не `parsed`.
Статусы разбора:
```
pending этим разбором ещё не смотрели
parsed разобрано всё, что в теле было
partial разобрано покрытое; в теле остались непокрытые секции
failed разобрать не удалось, точек нет
```
Источник истины — список непокрытых ключей; статус производен от него и от
факта отказа, в порядке `failed``partial``parsed`. Приоритет назван явно,
чтобы читатели (ретеншен, статистика) спрашивали статус, а не сравнивали список
со строкой.
Список непокрытых ключей SHALL сохраняться рядом с доставкой — именами ключей,
без содержимого секций. Он же ответ на вопрос «что останется потерянным, если
тело удалить»: для `stateOfMind` доставки HAE единственный источник, в экспорте
Apple его нет (находка 46). Поэтому список MUST сохраняться и при отказе
разбора, если разбор успел его собрать: `failed` с непустым списком — законное
состояние.
Запись списка MUST замещать прежнее значение целиком, включая замещение пустым:
иначе доставка, все секции которой стали покрытыми, осталась бы `partial`
навсегда.
Список — снимок покрытия **на момент свёртки**. Задача, которая начинает
разбирать секцию, тем же изменением SHALL переводить `partial`-строки с этим
ключом в `pending`; ретеншену позволено смотреть на `partial` только при
соблюдении этого правила.
Статусы, поставленные разбором, который частичного исхода не различал, доверия
не заслуживают: под `parsed` у них лежат и полностью разобранные доставки, и
доставки без метрик вовсе. Такие строки MUST переводиться в `pending` — «этим
разбором ещё не смотрели». Число точек у них до пересвёртки остаётся прежним: оно
производно от объектов витрины, которые никуда не делись.
#### Scenario: Доставка с непокрытой секцией отмечается частичной
- **WHEN** разбор доставки вернул непустой список непокрытых ключей
- **THEN** `parse_status` доставки равен `partial`
- **AND** список непокрытых ключей сохранён вместе с доставкой
- **AND** точки покрытой секции сохранены как обычно
#### Scenario: Доставка без непокрытых секций остаётся `parsed`
- **WHEN** разбор доставки не дал непокрытых ключей
- **THEN** `parse_status` равен `parsed`
- **AND** сохранённый список непокрытых ключей пуст
#### Scenario: Отказ разбора сильнее частичности
- **WHEN** разбор доставки завершился ошибкой
- **THEN** `parse_status` равен `failed`
- **AND** список непокрытых ключей сохранён, если разбор успел его собрать
#### Scenario: Пересвёртка после того, как секция стала покрытой
- **WHEN** доставка со статусом `partial` сворачивается повторно разбором,
который эту секцию уже покрывает
- **THEN** её статус становится `parsed`
- **AND** сохранённый список непокрытых ключей пуст
#### Scenario: Строки прежнего разбора переводятся в неразобранные
- **WHEN** база содержит доставки со статусом `parsed`, свёрнутые до появления
частичного статуса
- **THEN** после миграции их статус равен `pending`
- **AND** тела остаются в архиве, а повторная свёртка даёт то же состояние
## MODIFIED Requirements
### Requirement: Значения точек не попадают в логи
Данные о здоровье чувствительнее токенов. Система MUST NOT писать значения
точек и тела доставок в записи лога уровня выше `DEBUG`.
Непокрытые секции называются в логе **именами ключей**: имя секции — это форма
пакета, а не измерение. Содержимое секции в лог не попадает ни при каком уровне
выше `DEBUG`. Имена идут структурным атрибутом, а не склейкой в текст сообщения:
кодировщик экранирует управляющие символы, и имя из чужого тела не разрывает
построчный разбор логов.
Частичный разбор уровня записи не повышает: `partial` — установившееся состояние
половины потока (48 доставок из 99), и постоянный `WARN` обесценил бы уровень.
Повышает уровень другое — срабатывание границ списка: тело с сотнями секций или
с именем длиннее предела на HAE не похоже вовсе.
#### Scenario: Разбор доставки логируется без значений
- **WHEN** доставка разобрана
- **THEN** запись лога содержит счётчики (метрик, точек, объектов) и
идентификатор доставки
- **AND** не содержит ни значений точек, ни имён устройств
#### Scenario: Непокрытые секции названы именами ключей
- **WHEN** доставка содержит непокрытую секцию
- **THEN** запись лога содержит имена непокрытых ключей отдельным атрибутом
- **AND** не содержит ничего из содержимого этих секций
- **AND** уровень записи из-за одной лишь частичности не повышается
#### Scenario: Границы списка сработали
- **WHEN** список непокрытых ключей усечён по числу имён или по длине имени
- **THEN** запись лога имеет уровень `WARN`
- **AND** содержит число отброшенных имён
@@ -0,0 +1,84 @@
## 1. Разбор: перечисление непокрытых ключей
- [x] 1.1 `decodeMetrics``decodeEnvelope`: один `json.Decoder` идёт по
верхнему уровню, имя члена читается `Token()`, значение покрытого ключа
декодируется на месте, значение непокрытого проглатывается декодированием в
выбрасываемый `json.RawMessage` (ограничитель вложенности stdlib при этом
работает, в отличие от ручного счёта глубины)
- [x] 1.2 Покрытость — функция рядом с разбором, а не изменяемая пакетная карта;
`Result.Uncovered` отдаёт непокрытые отсортированными и без повторов
- [x] 1.3 Границы: не больше 32 имён (`Result.UncoveredDropped` считает
отброшенные), имя длиннее 64 байт декодированного имени обрезано по границе
рун, маркер `…` приписывается сверх предела
- [x] 1.4 Повтор ключа `metrics` даёт объединение секций; повтор непокрытого
ключа даёт одно имя. Тело без `data`, `data` не объект, `data` пустой,
`metrics` неверного типа — прежнее поведение (ошибка ровно там, где была)
- [x] 1.5 Ошибка после разобранной секции `metrics` точек не отдаёт
## 2. Хранилище: статус и список
- [x] 2.1 Константа `store.ParsePartial`; док-комментарий `ParsePending`
переписан на «этим разбором ещё не смотрели»
- [x] 2.2 Миграция: колонка `uncovered_sections TEXT NOT NULL DEFAULT '[]'` и
перевод существующих `parsed` в `pending`; в комментарии миграции сказано, что
по данным она односторонняя — `Down` снимает колонку, прежние статусы не
восстанавливает
- [x] 2.3 Исход разбора пишется структурой (`store.ParseOutcome`), а не растущим
списком позиционных параметров; список замещает прежнее значение целиком,
включая замещение пустым; `nil` и пустой срез записываются как `[]`
## 3. Свёртка: исход и лог
- [x] 3.1 `Stats.Uncovered` и `Stats.UncoveredDropped`; статус `partial` при
непустом списке, `failed` сильнее; при отказе список сохраняется, если разбор
успел его собрать
- [x] 3.2 Атрибуты `uncovered` (структурным `[]string`) и `uncovered_dropped` в
единственном логирующем чекпоинте; уровень из-за одной лишь частичности не
растёт, срабатывание границ даёт `WARN`
## 4. Тесты (приёмочные критерии)
- [x] 4.1 Фикстура `uncovered_sections.json`: точки метрик сохранены, ключи
`workouts` и `stateOfMind` в списке, статус `partial`
- [x] 4.2 Доставка из одной непокрытой секции: разбор без ошибки, ноль точек,
ключ в списке, статус `partial`
- [x] 4.3 Доставка из одних метрик: список пуст, статус `parsed`
- [x] 4.4 Детерминизм: тот же набор секций в разном порядке и с повтором ключа
даёт тот же список
- [x] 4.5 Границы: 40 ключей → 32 имени и счётчик отброшенных; длинное имя →
обрезка с маркером
- [x] 4.6 Удержание кучи: тело в десятки мегабайт, состоящее преимущественно из
непокрытой секции, удерживает не больше четырёх тел; тело из вложенных скобок
отвергается, а не съедает память
- [x] 4.7 Отказ всё или ничего: тело оборвано после секции метрик — ошибка, ноль
точек, `failed`
- [x] 4.8 Лог свёртки: имена ключей есть, содержимого секций нет; уровень при
обычной частичности не повышен
- [x] 4.9 Миграция: строка со статусом `parsed` становится `pending`, колонка
получает `[]`
- [x] 4.10 Пересвёртка: доставка `partial`, у которой список опустел, становится
`parsed` с пустым списком
- [x] 4.11 Прогон живого архива (`task verify:archive`): доставки без метрик
получают `partial` с непустым списком, повторный прогон состояния не меняет
- [x] 4.12 Сверка с состоянием ДО изменения: витрина, собранная из живого архива
новым кодом, совпадает по объектам, точкам и метрикам со снимком, снятым до
изменения
## 5. Документация
- [x] 5.1 `docs/database.md`: колонка, полный набор статусов, новый смысл
`pending`
- [x] 5.2 `docs/architecture.md`: частичный разбор в разделе приёма
- [x] 5.3 `docs/local-research.md`: находка о наборах секций в живом потоке
(99 доставок: 51 `metrics`, 24 `workouts`, 24 `stateOfMind`, секции не
смешиваются, пустых нет)
## 6. Замеры после реализации
- [x] 6.1 Живой архив (104 доставки): частично разобрано **50** — 25 `stateOfMind`
и 25 `workouts`. Отпечаток витрины `a59b38ea…` совпал с прогоном ДО изменения
на том же архиве: переписанный разбор конверта — строгий no-op для витрины
- [x] 6.2 Удержание кучи: тело 40 МиБ, из которых почти всё — непокрытая
секция, удерживает **0 МиБ**; тело из 100 000 уровней вложенности отвергается
- [x] 6.3 Живой сервис разобрал пришедшие с телефона доставки (5 штук) —
заодно закрыт пункт 6.3a задачи `razbor-metrik-v-obekty`
+103
View File
@@ -258,3 +258,106 @@ Export шлёт под одним именем, чтобы одно имя оз
- **THEN** ответ на приём остаётся `200`
- **AND** исход виден в `delivery.parse_status` и в записи лога
### Requirement: Перечисление непокрытых секций доставки
Разбор SHALL перечислять верхнеуровневые ключи объекта `data` и возвращать
вызывающему те из них, которые он не покрывает. Содержимое непокрытой секции
MUST NOT удерживаться после того, как разбор прошёл мимо неё: тела доходят до
42 МиБ, и удержание кучи здесь — часть контракта, а не деталь реализации.
Покрытым сегодня является ровно один ключ — `metrics`. Разбор и перечисление
MUST ходить по одному объявленному множеству покрытых имён: состояние «секция
разбирается, но числится непокрытой» невыразимо по построению.
Непокрытым ключ считается независимо от того, что лежит внутри: содержимое не
интерпретируется, поэтому и о пустоте секции разбор честно ничего не знает.
Измерено на живом архиве — пустых секций HAE не присылает ни разу (99 доставок).
Список SHALL быть каноничен: имена отсортированы, повторов нет. Порядок ключей в
JSON от HAE нестабилен, а значение уезжает в базу и сравнивается между
доставками.
Отсутствие непокрытых ключей и отсутствие секции `metrics` — разные события, и
оба нормальны: половина потока состоит из доставок без метрик вовсе (48 из 99).
#### Scenario: Незнакомая секция попадает в список непокрытых
- **WHEN** тело содержит `data.workouts` наряду с `data.metrics`
- **THEN** разбор возвращает `workouts` в списке непокрытых ключей
- **AND** точки секции `metrics` разбираются как обычно
#### Scenario: Доставка без метрик разбирается и не теряется
- **WHEN** тело содержит только `data.stateOfMind`
- **THEN** разбор завершается без ошибки, точек нет
- **AND** `stateOfMind` возвращается в списке непокрытых ключей
#### Scenario: Доставка из одних метрик непокрытых ключей не даёт
- **WHEN** единственный ключ `data``metrics`
- **THEN** список непокрытых ключей пуст
#### Scenario: Один и тот же набор секций даёт один и тот же список
- **WHEN** два тела несут те же секции в разном порядке, а одно из них
повторяет непокрытый ключ дважды
- **THEN** списки непокрытых ключей у них совпадают
#### Scenario: Содержимое непокрытой секции не удерживается в памяти
- **WHEN** тело в десятки мегабайт состоит преимущественно из непокрытой секции
- **THEN** после разбора удержано не больше четырёх размеров тела — та же
граница, что и для тела из метрик
- **AND** содержимое непокрытой секции в результат разбора не попадает
### Requirement: Границы списка непокрытых секций
Список непокрытых ключей MUST быть ограничен — не больше 32 имён и не больше
64 байт на имя: имена приходят из тела, которым отправитель управляет целиком.
Срабатывание любой из границ MUST быть видно вызывающему — молчаливое усечение
превратило бы список в уверенный, но неполный ответ на вопрос «что останется
потерянным, если тело удалить».
Число имён сверх предела отдаётся счётчиком. Имя длиннее предела обрезается по
границе рун, к обрезанному приписывается маркер `…` — сверх предела, а не внутри
него. Обрезка не инъективна, поэтому обрезанное имя сравнению со словарём
известных секций не подлежит.
Предел длины считается по байтам **декодированного** имени: escape-
последовательности JSON к этому моменту уже разобраны.
#### Scenario: Ключей больше предела
- **WHEN** объект `data` содержит 40 непокрытых ключей
- **THEN** список содержит 32 имени
- **AND** число отброшенных имён отдано отдельным счётчиком
#### Scenario: Имя ключа длиннее предела
- **WHEN** непокрытый ключ длиннее 64 байт
- **THEN** в списке лежит имя, обрезанное по границе рун, с маркером `…`
### Requirement: Отказ разбора остаётся всё или ничего
Разбор SHALL оставаться операцией «всё или ничего»: ошибка, встреченная
**после** того, как секция `metrics` уже разобрана (обрезанное тело, мусор в
следующем члене), MUST NOT оставлять точки в результате — доставка считается
неразобранной целиком.
Иначе часть точек оказалась бы в витрине под статусом, по которому доставку
никто не подберёт, и свёртка перестала бы быть детерминированной по журналу.
Повтор ключа `metrics` в одном объекте `data` SHALL давать объединение секций, а
не победу последней: молча терять точки нельзя.
#### Scenario: Тело оборвано после секции метрик
- **WHEN** тело содержит целую секцию `metrics`, а следующий член `data`
оборван
- **THEN** разбор завершается ошибкой и точек не отдаёт
#### Scenario: Секция метрик встречается дважды
- **WHEN** объект `data` содержит два ключа `metrics`
- **THEN** точки обеих секций попадают в результат
+99
View File
@@ -303,6 +303,17 @@ HTML-экранирования: `&`, `<` и `>` внутри точки обя
Данные о здоровье чувствительнее токенов. Система MUST NOT писать значения
точек и тела доставок в записи лога уровня выше `DEBUG`.
Непокрытые секции называются в логе **именами ключей**: имя секции — это форма
пакета, а не измерение. Содержимое секции в лог не попадает ни при каком уровне
выше `DEBUG`. Имена идут структурным атрибутом, а не склейкой в текст сообщения:
кодировщик экранирует управляющие символы, и имя из чужого тела не разрывает
построчный разбор логов.
Частичный разбор уровня записи не повышает: `partial` — установившееся состояние
половины потока (48 доставок из 99), и постоянный `WARN` обесценил бы уровень.
Повышает уровень другое — срабатывание границ списка: тело с сотнями секций или
с именем длиннее предела на HAE не похоже вовсе.
#### Scenario: Разбор доставки логируется без значений
- **WHEN** доставка разобрана
@@ -310,3 +321,91 @@ HTML-экранирования: `&`, `<` и `>` внутри точки обя
идентификатор доставки
- **AND** не содержит ни значений точек, ни имён устройств
#### Scenario: Непокрытые секции названы именами ключей
- **WHEN** доставка содержит непокрытую секцию
- **THEN** запись лога содержит имена непокрытых ключей отдельным атрибутом
- **AND** не содержит ничего из содержимого этих секций
- **AND** уровень записи из-за одной лишь частичности не повышается
#### Scenario: Границы списка сработали
- **WHEN** список непокрытых ключей усечён по числу имён или по длине имени
- **THEN** запись лога имеет уровень `WARN`
- **AND** содержит число отброшенных имён
### Requirement: Учёт частично разобранной доставки
Система SHALL отличать доставку, разобранную целиком, от доставки, в теле
которой остались непокрытые разбором секции. Доставка с непустым списком
непокрытых ключей MUST получать статус `partial`, а не `parsed`.
Статусы разбора:
```
pending этим разбором ещё не смотрели
parsed разобрано всё, что в теле было
partial разобрано покрытое; в теле остались непокрытые секции
failed разобрать не удалось, точек нет
```
Источник истины — список непокрытых ключей; статус производен от него и от
факта отказа, в порядке `failed``partial``parsed`. Приоритет назван явно,
чтобы читатели (ретеншен, статистика) спрашивали статус, а не сравнивали список
со строкой.
Список непокрытых ключей SHALL сохраняться рядом с доставкой — именами ключей,
без содержимого секций. Он же ответ на вопрос «что останется потерянным, если
тело удалить»: для `stateOfMind` доставки HAE единственный источник, в экспорте
Apple его нет (находка 46). Поэтому список MUST сохраняться и при отказе
разбора, если разбор успел его собрать: `failed` с непустым списком — законное
состояние.
Запись списка MUST замещать прежнее значение целиком, включая замещение пустым:
иначе доставка, все секции которой стали покрытыми, осталась бы `partial`
навсегда.
Список — снимок покрытия **на момент свёртки**. Задача, которая начинает
разбирать секцию, тем же изменением SHALL переводить `partial`-строки с этим
ключом в `pending`; ретеншену позволено смотреть на `partial` только при
соблюдении этого правила.
Статусы, поставленные разбором, который частичного исхода не различал, доверия
не заслуживают: под `parsed` у них лежат и полностью разобранные доставки, и
доставки без метрик вовсе. Такие строки MUST переводиться в `pending` — «этим
разбором ещё не смотрели». Число точек у них до пересвёртки остаётся прежним: оно
производно от объектов витрины, которые никуда не делись.
#### Scenario: Доставка с непокрытой секцией отмечается частичной
- **WHEN** разбор доставки вернул непустой список непокрытых ключей
- **THEN** `parse_status` доставки равен `partial`
- **AND** список непокрытых ключей сохранён вместе с доставкой
- **AND** точки покрытой секции сохранены как обычно
#### Scenario: Доставка без непокрытых секций остаётся `parsed`
- **WHEN** разбор доставки не дал непокрытых ключей
- **THEN** `parse_status` равен `parsed`
- **AND** сохранённый список непокрытых ключей пуст
#### Scenario: Отказ разбора сильнее частичности
- **WHEN** разбор доставки завершился ошибкой
- **THEN** `parse_status` равен `failed`
- **AND** список непокрытых ключей сохранён, если разбор успел его собрать
#### Scenario: Пересвёртка после того, как секция стала покрытой
- **WHEN** доставка со статусом `partial` сворачивается повторно разбором,
который эту секцию уже покрывает
- **THEN** её статус становится `parsed`
- **AND** сохранённый список непокрытых ключей пуст
#### Scenario: Строки прежнего разбора переводятся в неразобранные
- **WHEN** база содержит доставки со статусом `parsed`, свёрнутые до появления
частичного статуса
- **THEN** после миграции их статус равен `pending`
- **AND** тела остаются в архиве, а повторная свёртка даёт то же состояние