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

- половина потока (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,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** содержит число отброшенных имён