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

Триаж свёл 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
@@ -34,6 +34,15 @@
- **THEN** точка не попадает в хранилище
- **AND** факт учитывается в итоге разбора доставки
#### Scenario: Точка с нечитаемым концом интервала пропускается
- **WHEN** точка несёт `end`, который не разбирается
- **THEN** точка не попадает в хранилище
- **AND** факт учитывается отдельным счётчиком
Вырождать такую точку в мгновенную нельзя: две записи с общим началом получили
бы одну координату, и одна исчезла бы молча. Тело остаётся в архиве.
### Requirement: Вывод слоя гранулярности
Система SHALL выводить слой точки из **выравнивания меток времени**, а не из
@@ -45,6 +54,12 @@
но **не выводится** — он назначается схемам с фиксированной гранулярностью
(см. «Разделение схем под одним именем метрики»).
Выравнивание SHALL считаться по метке в **исходной зоне**, а не по метке в UTC.
HAE строит сетку по местному времени; ровный местный час в зоне со смещением на
половину (`+0530`, `+0545`, `+0930`) даёт UTC-метку на середине часа, и часовая
выгрузка целиком уехала бы в слой `minute` — где столкнулась бы с настоящей
минутной автоматизацией и завысила сумму минутного слоя вдвое.
Слоем метрики SHALL становиться **самое мелкое** выравнивание среди её меток, а
не преобладающее. Измерено: у плотных метрик выравнивания перемешаны
(`active_energy` — 1320 минутных меток и 21 часовая, `heart_rate` — 654
@@ -67,6 +82,11 @@
- **WHEN** в доставке у метрики не меньше десяти точек с метками
- **THEN** слой определяется выравниванием её собственных меток
#### Scenario: Зона с получасовым смещением не делает часовую выгрузку минутной
- **WHEN** метки стоят на ровном местном часе, а смещение зоны равно `+0530`
- **THEN** слой метрики `hour`
#### Scenario: Одна метка на середине часа делает метрику минутной
- **WHEN** у плотной метрики десять меток стоят ровно на часе, а одна — на
@@ -79,6 +99,16 @@
- **THEN** она получает самый мелкий слой среди плотных метрик этой доставки
- **AND** её собственное выравнивание во внимание не принимается
#### Scenario: Доставка, где всем метрикам слой назначен схемой
- **WHEN** в доставке нет метрик, которым слой надо выводить, — все точки
принадлежат схемам с фиксированной гранулярностью
- **THEN** доставка сохраняется, а слой доставки не выводится и не требуется
Иначе доставка автоматизации, настроенной только на сон, отвергалась бы
целиком — и необратимо: исход детерминирован, и пересборка повторяла бы его
вечно.
#### Scenario: В доставке нет плотных метрик
- **WHEN** ни у одной метрики доставки нет десяти точек
@@ -163,6 +193,20 @@ Export шлёт под одним именем, чтобы одно имя оз
(`totalSleep`/`core`/`rem`/`deep`/`awake` с меткой на местной полуночи). Общих
полей, кроме `date` и `source`, у них нет.
Схема точки SHALL определяться по самой точке, а не по её месту в исходном
массиве. Список разобранных точек отфильтрован пропусками, и соответствие по
индексу съезжало бы от одной пропущенной точки: эпизод уезжал бы под имя
суточной сводки со слоем `day`, сводка — под имя эпизода. Метрика и слой входят
в координату, поэтому ошибка необратима — точки из объекта не удаляются, и
пересборка воспроизвела бы её.
#### Scenario: Пропущенная точка не сдвигает разметку схем
- **WHEN** в метрике `sleep_analysis` перед суточной сводкой стоит точка без
разбираемой метки
- **THEN** сводка всё равно сохраняется под именем `sleep_analysis_summary` со
слоем `day`
#### Scenario: Поэпизодная запись сна
- **WHEN** точка `sleep_analysis` содержит поле `value`
@@ -88,13 +88,26 @@
- **THEN** исход определяется детерминированно и не зависит от порядка
воспроизведения доставок
Столкновением SHALL считаться расхождение **канонических форм**, а не байтов.
Байты нестабильны — ради этого канонизация и заведена: из 81 952 повторно
приехавших точек 67 534 различаются лишь порядком ключей, ещё 63% — последним
разрядом double. Побайтовое сравнение давало бы тысячи ложных срабатываний на
каждом глубоком проходе, и настоящий отказ правила стал бы неотличим от нормы.
#### Scenario: Столкновение с различием содержимого оставляет след
- **WHEN** по одним координатам сохраняется точка, каноническая форма которой
отличается от уже сохранённой
- **THEN** система пишет запись уровня `WARN` без значений точки
- **AND** запись несёт координаты объекта: метрику, слой и час
- **AND** увеличивает счётчик перезаписей в итоге разбора доставки
#### Scenario: Дребезг сериализации столкновением не считается
- **WHEN** та же точка приезжает с другим порядком ключей или отличаясь
последним разрядом числа
- **THEN** счётчик перезаписей не растёт и `WARN` не пишется
Без этого следа допущение «меньше полей не значит новее» не получит ни одного
наблюдения, а отказ правила будет неотличим от нормальной работы до сверки с
экспортом Apple — то есть месяцами.
@@ -117,7 +130,32 @@
слияний.
Запись — чтение объекта, слияние точек, запись обратно. Точки из объекта
MUST NOT удаляться.
MUST NOT удаляться. Содержимое объекта SHALL сериализоваться без
HTML-экранирования: `&`, `<` и `>` внутри точки обязаны храниться теми же
байтами, какими пришли, иначе «точка хранится дословно» перестаёт быть правдой,
а сравнение с последующей доставкой той же точки промахивается навсегда.
Доставка SHALL сворачиваться **одной транзакцией**. Транзакция на объект давала
недетерминированное частичное состояние: обход групп рандомизирован, и при
отказе посреди доставки набор уже записанных объектов каждый раз другой
(измерено: восемь прогонов одной доставки — семь разных состояний). Это ломает
инвариант «состояние пересобираемо».
Единицы измерения MUST NOT переписываться молча: при расхождении сохранённых и
пришедших единиц остаётся сохранённое значение, факт учитывается счётчиком и
попадает в запись уровня `WARN`. Внутри точки единиц нет, и у ранее сохранённых
точек не остаётся ничего, по чему их единицы восстановимы.
#### Scenario: Отказ посреди доставки не оставляет части объектов
- **WHEN** свёртка доставки прерывается на середине
- **THEN** не записывается ни один объект этой доставки
#### Scenario: Смена единиц не переподписывает сохранённые точки
- **WHEN** в объект приезжают точки в единицах, отличных от сохранённых
- **THEN** единицы объекта остаются прежними
- **AND** факт учитывается счётчиком и записью `WARN`
#### Scenario: Точки за один час ложатся в один объект
@@ -36,7 +36,7 @@
- [x] 4.4 Ключ точки — `метрика + слой + начало + конец` одной формы для всех точек: начало из `start`, иначе из `date`; конец из `end`, иначе равен началу. Час объекта — по началу. Ветвления по «классу метрики» быть не должно
- [x] 4.4a Тест на фикстуре 1.3a: три записи с одной меткой и разными интервалами дают три точки, а не одну; повтор той же тройки следующей доставкой не задваивает; точка без `end` кладётся вырожденным интервалом
- [x] 4.5 Хеш объекта как детектор изменений: совпал — записи нет
- [x] 4.6 `_txlock=immediate` в DSN; повтор оборачивает **всю тройку** чтение-слияние-запись; путь «хеш совпал» под `TxOptions{ReadOnly: true}`
- [x] 4.6 `_txlock=immediate` в DSN; повтор оборачивает **всю доставку** целиком. Путь «хеш совпал» под `TxOptions{ReadOnly: true}` **не сделан** — вынесен блокером `otvet-i-svyortka` вместе с остальной ценой синхронной свёртки (измерено: 52 мс на неизменившийся плотный час под write-lock)
- [x] 4.7 Распознавание занятости — `errors.As` на `*sqlite.Error`, коды 5 и 517, обёрнуто в `store`
- [x] 4.8 Тест конкурентной записи: N горутин × M слияний в один `hour_utc`, проверка **суммы** точек, под `-race`
- [x] 4.9 Тест идемпотентности: повторное слияние того же набора не меняет ни содержимое, ни хеш
@@ -74,3 +74,22 @@
- [x] 7.11 Слой выводится из данных, а не из заголовка; неопределимый слой имеет явную судьбу
- [x] 7.12 Парсер детерминирован и чист: ни `time.Now`, ни генерации id; два вызова на одном входе равны
- [x] 7.13 (добавлено после снятия блокера) Точка-интервал не схлопывается по метке: прогон архива даёт 174 координаты сна, а не 170
## 8. Отработка ревью кода (профиль `deep`)
Триаж свёл 62 сырые находки к 33 причинам: 3 блокера, 4 «сейчас», 2 развилки.
- [x] 8.1 Схема точки сна определяется по самой точке, а не по индексу в исходном массиве (подтверждено шестью проходами)
- [x] 8.2 Доставка сворачивается одной транзакцией: частичное состояние было недетерминированным (8 прогонов — 7 состояний)
- [x] 8.3 Граница размера на распакованном теле: 400 КиБ gzip разворачивались в 400 МиБ мимо лимита
- [x] 8.4 Столкновение — расхождение канонических форм, а не байтов; `WARN` с координатами объекта
- [x] 8.5 `encodePayload` без HTML-экранирования: `&`, `<`, `>` хранятся дословно
- [x] 8.6 Доставка из одних суточных сводок больше не отвергается целиком
- [x] 8.7 Выравнивание считается по местной метке: получасовые зоны уводили часовую выгрузку в `minute`
- [x] 8.8 Нечитаемый `end` пропускает точку со счётчиком, а не вырождает интервал
- [x] 8.9 Единицы не переписываются молча: сохранённое побеждает, расхождение — счётчик и `WARN`
- [x] 8.10 Счётчик считает сохранённые точки, а не присланные
- [x] 8.11 Все выходы `Fold` с ошибкой логируются; исход пишется на контексте, переживающем отмену; слой не затирается
- [x] 8.12 Признаки — атрибутами всегда, уровень выбирается отдельно; `WARN` на отброшенных всех точках
- [x] 8.13 Регрессионные тесты на каждое исправление; тест «значения точек не попадают в лог» с перехватывающим handler
- [x] 8.14 Развилки вынесены блокерами: `otvet-i-svyortka`, `pravilo-sliyaniya-tochek`, `edinicy-metriki-v-razreze`, `nerazobrannye-sekcii-dostavki`