## 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`** → сверка витрины, собранной из живого архива, со снимком, снятым до изменения: совпадение по объектам, точкам и метрикам, а не только сходимость нового кода с самим собой.