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