отработаны находки ревью кода
Триаж свёл 62 сырые находки девяти проходов к 33 причинам: 3 блокера, 4 «сейчас», 2 развилки. Все закрыты регрессионными тестами. - схема точки сна определяется по самой точке, а не по индексу в исходном массиве: одна пропущенная точка меняла эпизод и сводку местами - доставка сворачивается одной транзакцией: частичное состояние было недетерминированным (восемь прогонов — семь состояний) - граница размера на распакованном теле: 400 КиБ gzip разворачивались в 400 МиБ мимо max_body_mb - столкновение — расхождение канонических форм, а не байтов; WARN с координатами объекта; payload без HTML-экранирования - выравнивание по местной метке: получасовые зоны уводили часовую выгрузку в minute - доставка из одних суточных сводок больше не отвергается целиком - единицы не переписываются молча; счётчик считает сохранённые точки - все выходы Fold логируются, исход пишется на переживающем отмену контексте Четыре развилки вынесены блокерами в беклог.
This commit is contained in:
+16
-4
@@ -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)
|
||||
|
||||
@@ -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) — Доставки копятся непрозрачными телами — точек в хранилище нет вовсе, всё остальное упирается в это
|
||||
|
||||
@@ -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) — тот же признак закрыл
|
||||
бы и её.
|
||||
@@ -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) — метка «ответ не уложился в
|
||||
таймаут» должна попасть туда.
|
||||
@@ -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` → «Разрешение столкновений по полноте».
|
||||
Reference in New Issue
Block a user