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

278 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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`** → сверка витрины, собранной из
живого архива, со снимком, снятым до изменения: совпадение по объектам,
точкам и метрикам, а не только сходимость нового кода с самим собой.