отработаны находки ревью кода

Триаж свёл 62 сырые находки девяти проходов к 33 причинам: 3 блокера,
4 «сейчас», 2 развилки. Все закрыты регрессионными тестами.

- схема точки сна определяется по самой точке, а не по индексу в исходном
  массиве: одна пропущенная точка меняла эпизод и сводку местами
- доставка сворачивается одной транзакцией: частичное состояние было
  недетерминированным (восемь прогонов — семь состояний)
- граница размера на распакованном теле: 400 КиБ gzip разворачивались в
  400 МиБ мимо max_body_mb
- столкновение — расхождение канонических форм, а не байтов; WARN с
  координатами объекта; payload без HTML-экранирования
- выравнивание по местной метке: получасовые зоны уводили часовую выгрузку
  в minute
- доставка из одних суточных сводок больше не отвергается целиком
- единицы не переписываются молча; счётчик считает сохранённые точки
- все выходы Fold логируются, исход пишется на переживающем отмену контексте

Четыре развилки вынесены блокерами в беклог.
This commit is contained in:
av
2026-08-01 19:02:05 +03:00
parent 32f21044e4
commit cd7a4c1493
23 changed files with 1340 additions and 175 deletions
+16 -4
View File
@@ -230,6 +230,18 @@ HRV); у накопительных — только `date`. Поэтому то
безопасности, исход разбора виден в логе, в `delivery.parse_status` и в
`/stats`, а доразобрать их можно командой `reindex`.
- **413** — тело больше допустимого. Граница стоит на **распакованном**
потоке, а не только на сжатом: `MaxBytesReader` поверх `r.Body` ограничивает
то, что приехало по сети, а в память попадает то, что из этого развернулось.
Измерено: 400 КиБ сжатого тела давали 400 МиБ и гигабайт выделений при
лимите в мегабайт. Потолок степени сжатия gzip около 1030:1, так что при
штатных 64 МиБ речь о десятках гигабайт на запрос, и параллельные
складываются. Цена отказа здесь наивысшая в проекте: приём — единственное
место, где поток вообще существует, и доставка, не попавшая в архив, не
попадает в журнал. Та же граница действует при чтении тела из архива —
иначе тело между двумя границами принималось бы с `200`, а потом вечно
валилось бы при каждой пересборке.
Причина такого разделения: неизвестно, шлёт ли HAE отклонённый пакет
повторно при периоде «Since Last Sync». Если не шлёт, строгий приём означал
бы дыру в истории. Многоуровневая синхронизация страхует тот же риск с другой
@@ -340,11 +352,11 @@ HAE. Значит для него доставки не хвост журнал
```
delivery(id, received_at, automation_name, automation_id, aggregation,
period, session_id, bytes, sha256, raw_path, parse_status, points,
headers)
headers, derived_layer)
bucket(metric, layer, hour_utc, hash, points_count, first_ts, last_ts,
units, payload BLOB, first_delivery_id, updated_at, sealed)
PK (metric, layer, hour_utc)
bucket(metric, layer, hour_utc, units, payload BLOB, content_hash, points,
first_ts, last_ts, first_delivery_id, sealed, created_at, updated_at)
PK (metric, layer, hour_utc) WITHOUT ROWID
workout(id PK, name, start_utc, end_utc, tz_offset, duration_sec,
payload JSON, delivery_id, updated_at)
+4
View File
@@ -12,6 +12,10 @@
одному — прерывать поток ради каждого дороже, чем накопить.
## блокеры
- [Разнести ответ приёма и свёртку доставки](otvet-i-svyortka.md) — синхронная свёртка не помещается в write_timeout: широкие проходы получают обрыв вместо 200
- [Правило выбора победителя при столкновении точек](pravilo-sliyaniya-tochek.md) — полнота считается числом ключей, поэтому мусорные поля бьют измерение
- [Единицы метрики: часть координаты или свойство объекта](edinicy-metriki-v-razreze.md) — смена единиц в настройках HAE делит час на точки в разных единицах
- [Судьба доставки, у которой разобрана не вся секция data](nerazobrannye-sekcii-dostavki.md) — тело с одним stateOfMind помечается parsed, а ретеншен снесёт его как разобранное
## высокий
- [Разбор метрик в часовые объекты](razbor-metrik-v-obekty.md) — Доставки копятся непрозрачными телами — точек в хранилище нет вовсе, всё остальное упирается в это
+53
View File
@@ -0,0 +1,53 @@
# Единицы метрики: часть координаты или свойство объекта
**Приоритет:** блокеры
Вынуто ревью кода задачи `razbor-metrik-v-obekty` (профиль `deep`, найдено
тремя проходами независимо).
## Что решить
Единицы измерения живут колонкой объекта — одна на все точки часа. Что делать,
когда в тот же час приезжают точки в **других** единицах.
## Почему это не мелочь
Внутри точки единиц нет: проверено на 89 доставках, поле `units` не
встретилось ни разу, оно живёт только на уровне метрики. Значит у точки,
сохранённой раньше, не остаётся **ничего**, по чему её единицы восстановимы —
кроме сырого архива, пока он жив.
Смена реальна и не требует злого умысла: переключатель единиц в настройках
HAE, смена локали телефона, переименование единицы в новой версии приложения.
## Варианты и цена
**(1) Единицы — часть координаты объекта** (`метрика + слой + единицы + час`).
Цена: миграция, ключ шире, каталог разрезов обязан показывать единицы. Зато
точки никогда не подписаны чужим — разные единицы просто разные ряды.
**(2) Первое непустое побеждает, расхождение — `WARN` и счётчик** (сделано
сейчас как безопасное умолчание).
Цена: нулевая, уже работает. Но час, начавшийся в километрах, останется
километровым навсегда, даже если телефон окончательно переехал на мили.
**(3) Хранить обе величины у объекта** (`units` и `units_seen`).
Цена: малая, но это откладывание решения: читателю всё равно придётся
выбирать.
## Рекомендация
**(1)**, если единицы вообще могут меняться на живом потоке — а они могут.
Сегодняшний (2) безопасен, но оставляет систематическую ложь в подписи.
## Что стоит без решения
Ничего не теряется: сохранённые единицы больше **не перезаписываются** молча
(было — пришедшие всегда побеждали), расхождение считается в
`MergeStats.UnitsConflicts` и даёт `WARN` в свёртке.
Отдельная тонкость, которую надо закрыть тем же решением: единицы не входят в
хеш содержимого, поэтому доставка с теми же точками и исправленными единицами
уходит по ветке «ничего не изменилось» и колонку не трогает. То есть единицы
сегодня обновляются тогда и только тогда, когда изменилось содержимое, —
правило, которого никто не формулировал.
@@ -0,0 +1,58 @@
# Судьба доставки, у которой разобрана не вся секция data
**Приоритет:** блокеры
Вынуто ревью кода задачи `razbor-metrik-v-obekty` (профиль `deep`, проход
негативного пространства). **Решить до реализации ретеншена.**
## Что решить
Разбор читает только `data.metrics`. Доставка, состоящая из `workouts`,
`stateOfMind`, `symptoms` или `ecg`, помечается `parse_status=parsed` с нулём
точек — неотличимо от доставки с пустой секцией метрик.
## Почему это блокер, а не задача
Само по себе это некритично: секции пока не разбираются сознательно, тела
лежат в архиве. Опасность в **сцепке с ретеншеном**.
Ретеншен по замыслу срезает архив до следующего проверенного экспорта. Если он
будет ориентироваться на `parse_status`, он снесёт тела, которые числятся
разобранными, — а для `stateOfMind` это необратимо: **в экспорте Apple его
нет** (находка 46), доставки HAE для него единственный источник.
То есть цена ошибки здесь не «придётся пересобрать», а «истории состояния
разума больше не существует».
## Варианты и цена
**(1) Разбор возвращает список верхнеуровневых ключей `data`, которые он не
покрыл; статус `partial`.**
Цена: малая — один `json.Decoder` по верхнему уровню, без чтения содержимого.
Ретеншен и `reindex` получают честный признак, а в логе появляется момент,
когда поток принёс новую секцию (ровно то, ради чего заведена задача
`proverka-novyh-sekcij`).
**(2) Считать `parsed` только доставку, у которой разобрано всё; остальные —
`pending`.**
Цена: малая, но `pending` перестаёт означать «ещё не смотрели», и подбор
зависших доставок теряет свой признак.
**(3) Ничего не менять, но ретеншену запретить смотреть на `parse_status`
резать только по дате экспорта.**
Цена: нулевая сейчас, но ретеншен становится тупым и не защищает от «тело
разобрано неверно, а мы его уже срезали».
## Рекомендация
**(1).** Список непокрытых ключей — дешёвая честность, и он же закрывает
задачу «не пропустить момент, когда поедет новая секция».
## Что стоит без решения
Ничего: сегодня ретеншена нет, тела не удаляются. Задача — не дать сцепке
сложиться позже.
Связано: [retenshen-syrogo-arhiva](retenshen-syrogo-arhiva.md) — решить **до**
неё; [proverka-novyh-sekcij](proverka-novyh-sekcij.md) — тот же признак закрыл
бы и её.
+80
View File
@@ -0,0 +1,80 @@
# Разнести ответ приёма и свёртку доставки
**Приоритет:** блокеры
Вынуто ревью кода задачи `razbor-metrik-v-obekty` (профиль `deep`, находка №4
триажа, severity major).
## Что решить
Свёртка выполняется **синхронно внутри обработчика запроса**, поэтому время
ответа равно времени свёртки. Вопрос: разносить ли их, и какой ценой.
## Оракул: измерено
`WriteTimeout` в Go ставится в `readRequest`**до** чтения тела и до вызова
обработчика (`net/http/server.go:993-997`, прочитано в исходниках). Значит
30 секунд по умолчанию это бюджет на всё сразу: дочитать до 64 МиБ по
мобильной сети, сделать `fsync` архива, вставить доставку и свернуть.
Воспроизведено минимальной программой: сервер с `WriteTimeout=200ms`,
обработчик спит 500 мс.
```
handler: WriteHeader(200), body Write err=<nil>
client: elapsed=501ms err=EOF
```
Сервер считает, что отдал `200` — ошибки записи не видно, ответ ушёл в буфер и
сбрасывается позже. Клиент получил обрыв. Код обработчика этого не видит, а
`accessLog` честно запишет `status_code=200`: единственный сегодняшний канал
наблюдаемости в этом сценарии врёт.
Стоимость свёртки измерена **до** перехода на одну транзакцию на доставку:
| тело | объектов | свёртка |
|---|---|---|
| 80 КиБ | 1001 | 815 мс |
| 323 КиБ | 4001 | 3.07 с |
| 1302 КиБ | 16001 | 11.07 с |
Одна транзакция на доставку убрала около 0.7 мс на объект (прогон живого
архива ускорился с 64 до 52 секунд), но порядок величины остался: широкая
доставка по-прежнему измеряется секундами.
Бьёт это по **широким проходам**`Today`, `Previous 7 Days`, ручной
экспорт, — то есть ровно по тем, ради которых заведён инвариант «дыры
закрываются сами».
## Варианты и цена
**(а) Отвечать `200` сразу после архивации и учёта; свёртка — воркером в
порядке журнала, с подбором `pending` при старте.**
Цена: средняя — воркер, очередь, подбор при старте. Бонусом закрываются ещё
две дыры: параллельные доставки одной автоматизации перестают гонять
наследование слоя (сейчас вторая может не найти слоя первой и уйти в
`failed`), и доставка, застрявшая в `pending` из-за сбоя записи, наконец
кем-то подбирается.
**(б) Поднять `write_timeout` до согласованного с `foldTimeout`.**
Цена: малая. Но худший случай (64 МиБ) всё равно минуты, и молчание
`accessLog` остаётся.
**(в) Оставить как есть, задокументировав потолок размера доставки.**
Цена: нулевая. Широкие проходы продолжают рваться.
## Рекомендация
**(а).** Единственный вариант, который решает причину, а не симптом, и попутно
снимает две смежные находки. Он же приближает `reindex`: подбор `pending` при
старте — его половина.
## Что стоит без решения
Ничего: свёртка работает, просто рискует не уложиться в таймаут на самых
широких доставках. Данные при этом не теряются — тело ложится в архив **до**
свёртки.
Связано: [reindex-iz-arhiva](reindex-iz-arhiva.md),
[stats-nablyudaemost](stats-nablyudaemost.md) — метка «ответ не уложился в
таймаут» должна попасть туда.
+65
View File
@@ -0,0 +1,65 @@
# Правило выбора победителя при столкновении точек
**Приоритет:** блокеры
Вынуто ревью кода задачи `razbor-metrik-v-obekty` (профиль `deep`, находка №7
триажа, severity major). Трогает записанный инвариант «при столкновении
выигрывает более полная точка».
## Что решить
Как выбирать победителя, когда по одним координатам приехали разные
содержимые. Сегодняшнее правило измеримо неверно в двух местах.
## Оракул: измерено
**Полнота — это счётчик ключей**, чьё значение не `null`, не `""` и не пусто.
Поэтому `{}`, `[]`, `0` и `false` считаются содержательными:
```
точка {"qty":0,"a":0,"b":0,"c":{},"d":[]} полнота 5
точка {"date":"…","qty":123.4} полнота 2
```
Вторая точка — настоящее измерение — проигрывает первой и стирается
безвозвратно. Восстановить можно только из сырого архива, пока он жив.
**Тай-брейк при равной полноте** — лексикографический порядок канонических
форм, то есть исход зависит от самого значения. Для накопительной метрики,
которую досчитывают задним числом, это систематическая победа **меньшего**
числа: `qty:1` бьёт `qty:100`. Недосчёт, неотличимый от нормы.
Частота на живом потоке **не измерена** — стоит прогнать по архиву до решения.
## Варианты и цена
**(1) Полнота по множеству ключей: побеждает надмножество; несравнимые
множества — объединять поля, а не выбирать точку целиком.**
Цена: средняя — правка спеки, `resolve` и тестов. Самое честное прочтение
инварианта «ничего не теряем молча»: при несравнимых наборах не теряется
ничего вообще.
**(2) Оставить счёт ключей, но не считать содержательными `{}`, `[]`, `0`,
`false`, `""`.**
Цена: малая. Правило остаётся играбельным (точку с лишними непустыми полями
никто не мешает прислать), и произвол тай-брейка не решён.
**(3) При равной полноте побеждает точка более поздней доставки по
`received_at`.**
Цена: малая, но спека должна признать зависимость от журнала, и нужен
детерминированный порядок **внутри** одной доставки — а именно там измерены
столкновения (31 координата в 21 доставке из 89).
## Рекомендация
**(1).** Объединение полей при несравнимых наборах — единственный вариант, при
котором столкновение вообще перестаёт быть выбором «кого потерять».
## Что стоит без решения
Правило работает и теперь **наблюдаемо**: столкновение считается расхождением
канонических форм (а не байтов, как было), даёт `WARN` с координатами объекта
и счётчик `overwrites`. Если правило начнёт терять — это станет видно в логе,
а не через месяцы при сверке с экспортом Apple.
Связано: `openspec/specs/storage` → «Разрешение столкновений по полноте».