Files
av d79189be18 docs: документация переведена на канон av-dev-pm
- беклог и план переехали в docs/tasks (38 задач, 11 целей), слаги
  переименованы с транслита на английские, 85 ссылок поправлены
- conventions.md разобран в docs/conventions/, local-research.md — в
  docs/research/, review-journal.md — в docs/review.md с разделом настройки
  конвейера; заведены security.md, adr/ и .pm.json
- шаг docs.py check добавлен в task gate; поведение в architecture.md помечено
  девятью маркерами долга, database.md получил настройки с числовым значением
2026-08-03 17:14:53 +03:00

22 KiB
Raw Permalink Blame History

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. Момент появления новой секции — отдельная задача (unseen-sections-check), и она будет опираться на сохранённый список.

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