Дозакрыты находки ревью по слиянию сущностей
- Правило покрытия получило второй разряд (условный, как у точек), запрет вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и ряд из null больше не затирают маршрут. Победитель внутри доставки стал функцией множества версий — общим помощником с точками, — а провенанс поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину к прежнему содержимому. - Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает на старте; текст ошибки разбора не несёт значений из тела. - Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты: безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя был квадратичен по числу присланных версий одного ключа.
This commit is contained in:
+265
@@ -0,0 +1,265 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Разбор секций с собственными идентификаторами
|
||||
|
||||
Система SHALL разбирать секции тела, элементы которых несут собственный `id`, в
|
||||
**сущности**, а не в точки: у сущности нет ни слоя, ни координатного ключа
|
||||
`метрика + слой + начало + конец` — её адресует сам `id`.
|
||||
|
||||
Покрываются две такие секции: `data.workouts` и `data.stateOfMind`. Секции
|
||||
`ecg`, `symptoms`, `cycleTracking`, `medications` и `heartRateNotifications`
|
||||
покрытыми MUST NOT становиться: живой поток не приносил их ни разу (118
|
||||
доставок), их форма никем не наблюдалась, а полнота покрытия HealthKit ради
|
||||
полноты целью проекта не является. Они остаются в списке непокрытых, и доставка
|
||||
с ними остаётся `partial`.
|
||||
|
||||
Из тренировки разбор SHALL брать только то, по чему потом идёт выборка:
|
||||
идентификатор, имя, начало, конец, офсет исходной зоны и длительность. Всё
|
||||
остальное — включая маршрут, внутренние ряды (`heartRateData`,
|
||||
`activeEnergy`, `heartRateRecovery`) и сводки — MUST храниться содержимым
|
||||
сущности **дословно**, теми же байтами, какими пришло. Раскладывать структуру
|
||||
тренировки по колонкам значило бы решить за Apple, что в ней главное: сводки
|
||||
дублируют ряды (`distance` — это сумма `walkingAndRunningDistance`), а набор
|
||||
полей зависит от типа тренировки (у уличной есть `route`, `avgSpeed`,
|
||||
`flightsClimbed`, у домашней — `temperature`, `humidity`, `intensity`).
|
||||
|
||||
**Значение заголовка не того ТИПА SHALL стоить одного поля, а не сущности.**
|
||||
Каждое поле заголовка читается мягко: строка берётся, когда значение является
|
||||
строкой, и считается отсутствующей во всех прочих случаях. Правило уже записано
|
||||
для длительности («нечисловое значение — это пропуск ОДНОГО поля, а не сломанная
|
||||
сущность») и распространяется на весь заголовок. Иначе `name`, приехавшее
|
||||
числом, уносит тренировку вместе с маршрутом, а доставка при этом числится
|
||||
разобранной.
|
||||
|
||||
Мягкость MUST достигаться конструкцией, которая **не полагается на дозаполнение
|
||||
остальных полей** библиотекой разбора: `encoding/json` при несовпадении типа
|
||||
«skips that field and completes the unmarshaling as best it can», но тут же
|
||||
оговаривает, что дозаполнение полей **после** проблемного не гарантировано.
|
||||
Разбор, построенный на распознавании ошибки типа постфактум, перестал бы быть
|
||||
функцией тела: одна и та же тренировка давала бы разный заголовок.
|
||||
|
||||
Граница правила называется вслух: оно закрывает смену **типа** значения, но не
|
||||
смену **формата строки**. Наблюдавшийся дрейф — формата дат (разбор дат уже
|
||||
зависит от секции пакета), и метка в незнакомом формате по-прежнему уносит
|
||||
сущность целиком; закрыть это может только хранение сущности с неразобранной
|
||||
меткой, а это отдельная задача. Пропуск при этом перестаёт быть невидимым: он
|
||||
доходит до учётной записи доставки.
|
||||
|
||||
Идентификатор исключением из мягкости MUST быть: сущность без строкового `id`
|
||||
не адресуема, и приведение чужого нестрокового значения к строке было бы
|
||||
выдумыванием идентичности за источник. Такая сущность пропускается тем же
|
||||
счётчиком, что и сущность без `id`.
|
||||
|
||||
Началом сущности при **присутствующем, но непрочитанном** `start` подставляться
|
||||
`date` MUST NOT — включая `start: null`.
|
||||
«Значение не той формы» и «значения нет» здесь различаются: фолбэк на `date`
|
||||
существует для сущностей, у которых `start` не прислан вовсе, а подстановка
|
||||
другого поля вместо непонятого даёт метку **другого момента времени**, ничем не
|
||||
отличимую от настоящей. Такой `start` SHALL считаться неразбираемой меткой —
|
||||
тем же исходом и тем же счётчиком, что метка незнакомого формата. Различать
|
||||
надо именно «ключ был», а не «значение не той формы»: `null` тоже не даёт
|
||||
строки, и без этого различения он молча уводил бы тренировку на другой момент
|
||||
времени.
|
||||
|
||||
Мягкое чтение SHALL задавать поле целиком на каждое вхождение ключа, а не
|
||||
накапливать признаки между вызовами. JSON допускает повтор ключа с семантикой
|
||||
«побеждает последнее», и разбор ей уже следует; накопленный признак сделал бы
|
||||
заголовок функцией истории вызовов, а не тела.
|
||||
|
||||
Элемент секции, не являющийся объектом JSON, SHALL уходить в счётчик «не
|
||||
разобралось как объект» — включая `null`. Разбор в структуру на `null` ошибки не
|
||||
даёт, поэтому такой элемент без явной проверки попадал бы в счётчик «нет `id`»,
|
||||
и сменившаяся форма СЕКЦИИ диагностировалась бы как сменившаяся форма
|
||||
ИДЕНТИФИКАТОРА — ради различения которых два счётчика и заведены.
|
||||
|
||||
Длительность SHALL браться из тела, а не вычисляться из начала и конца: HAE
|
||||
шлёт `91.746` секунды при интервале в 91 секунду, и вычисленное значение молча
|
||||
разошлось бы с присланным. Отсутствие или нечисловое значение длительности
|
||||
сущность MUST NOT отбрасывать; такая длительность SHALL быть выражена
|
||||
отсутствием значения, а не нулём — ноль является законной длительностью, и
|
||||
потребитель не отличил бы «источник не прислал» от «измерено ноль».
|
||||
|
||||
Началом сущности SHALL быть `start`, при его отсутствии — `date`. Конец берётся
|
||||
из `end`; при отсутствии или неразбираемости конца он SHALL равняться началу, а
|
||||
истина остаётся в содержимом. Вырождение интервала здесь безопасно, в отличие от
|
||||
точки: ключ сущности — `id`, схлопывать координаты нечем. Офсет исходной зоны
|
||||
SHALL браться из начала: колонка одна, а тренировка через смену зоны дала бы
|
||||
два разных.
|
||||
|
||||
Из записи разбор SHALL брать идентификатор, род секции, метку времени и офсет;
|
||||
всё остальное хранится дословно. Род записи SHALL быть верхнеуровневым ключом
|
||||
секции HAE **дословно** (`stateOfMind`, не `state_of_mind`): инвариант «форма
|
||||
Apple не транслируется» относится и к именам секций.
|
||||
|
||||
Длина идентификатора SHALL быть ограничена, и сущность с более длинным `id`
|
||||
SHALL пропускаться тем же счётчиком, что и сущность без `id`. Идентификатор
|
||||
приходит из тела, которым отправитель управляет целиком, а уезжает и в ключ
|
||||
таблицы, и в записи лога; правило то же, что уже действует для имён непокрытых
|
||||
секций.
|
||||
|
||||
Ряд пульса **внутри** тренировки MUST NOT попадать в метрику `heart_rate`:
|
||||
это разные сущности хранилища. Пульс приезжает дважды — в общем потоке метрик и
|
||||
внутри тренировки, — и смешение задвоило бы ряд.
|
||||
|
||||
Сущность без `id` либо без разбираемой метки времени SHALL пропускаться со
|
||||
счётчиком, не роняя разбор остального: тело остаётся в архиве, и доставку
|
||||
подберёт пересборка, когда разбор научится её понимать. Счётчики пропусков SHALL
|
||||
доходить до учётной записи доставки, а не только до лога, — иначе ретеншен,
|
||||
решающий по базе, получит ответ «терять нечего» там, где потеряна тренировка.
|
||||
|
||||
Отсутствие покрытой секции в теле ошибкой быть MUST NOT: доставки из одних
|
||||
метрик — большинство потока.
|
||||
|
||||
#### Scenario: Тренировка разбирается вместе с маршрутом
|
||||
|
||||
- **WHEN** тело содержит `data.workouts` с тренировкой, несущей `route`
|
||||
- **THEN** разбор отдаёт сущность с идентификатором, именем, началом, концом,
|
||||
офсетом и длительностью
|
||||
- **AND** её содержимое несёт маршрут и внутренние ряды исходными байтами
|
||||
|
||||
#### Scenario: Ряд пульса тренировки не становится метрикой
|
||||
|
||||
- **WHEN** тренировка содержит `heartRateData`
|
||||
- **THEN** точки этого ряда не попадают в точки метрик
|
||||
- **AND** остаются внутри содержимого сущности
|
||||
|
||||
#### Scenario: Запись состояния разума разбирается
|
||||
|
||||
- **WHEN** тело содержит `data.stateOfMind` с элементом, несущим `id` и `start`
|
||||
- **THEN** разбор отдаёт запись с родом `stateOfMind`, идентификатором, меткой
|
||||
времени и содержимым исходными байтами
|
||||
|
||||
#### Scenario: Имя не той формы не уносит тренировку
|
||||
|
||||
- **WHEN** у тренировки с корректными `id` и `start` поле `name` приехало
|
||||
числом
|
||||
- **THEN** тренировка попадает в результат разбора с пустым именем
|
||||
- **AND** её содержимое сохраняется дословно, включая маршрут
|
||||
|
||||
#### Scenario: Конец не той формы не уносит тренировку
|
||||
|
||||
- **WHEN** у тренировки с корректными `id` и `start` поле `end` приехало числом
|
||||
- **THEN** тренировка попадает в результат разбора, а конец равен началу
|
||||
|
||||
#### Scenario: Сущность без идентификатора пропускается
|
||||
|
||||
- **WHEN** элемент покрытой секции не несёт `id`, либо `id` пуст, либо `id`
|
||||
приехал не строкой
|
||||
- **THEN** сущность в результат разбора не попадает
|
||||
- **AND** факт учитывается счётчиком, а разбор остальных сущностей продолжается
|
||||
|
||||
#### Scenario: Элемент секции не является объектом
|
||||
|
||||
- **WHEN** элемент покрытой секции не разбирается как объект JSON
|
||||
- **THEN** сущность в результат разбора не попадает
|
||||
- **AND** факт учитывается **отдельным** счётчиком, а соседние сущности
|
||||
разбираются как обычно
|
||||
|
||||
Отдельным, а не общим с «нет `id`»: доставка, где не разобрался сам элемент, —
|
||||
это сменившаяся форма секции, а доставка без `id` — сменившаяся форма
|
||||
идентификатора. Ронять из-за такого элемента всю доставку нельзя тем более:
|
||||
`failed` фоновая свёртка не подбирает никогда, и вместе с одной кривой
|
||||
тренировкой в него уехали бы записи `stateOfMind` той же доставки.
|
||||
|
||||
#### Scenario: Элемент секции не объект — свой счётчик
|
||||
|
||||
- **WHEN** элемент покрытой секции пришёл как `null`, строка, число или массив
|
||||
- **THEN** факт учитывается счётчиком «не разобралось как объект»
|
||||
- **AND** счётчик «нет `id`» не растёт
|
||||
|
||||
#### Scenario: Повтор ключа метки решается последним значением
|
||||
|
||||
- **WHEN** у элемента ключ `start` встречается дважды, и валидная метка стоит
|
||||
последней
|
||||
- **THEN** сущность попадает в результат разбора с этой меткой
|
||||
|
||||
#### Scenario: Сущность без разбираемой метки времени пропускается
|
||||
|
||||
- **WHEN** элемент покрытой секции несёт `id`, но его метка времени не
|
||||
разбирается ни одним из поддерживаемых форматов либо пришла не строкой
|
||||
(включая `null`)
|
||||
- **THEN** сущность в результат разбора не попадает
|
||||
- **AND** факт учитывается счётчиком
|
||||
- **AND** поле `date` вместо непонятого `start` не подставляется
|
||||
|
||||
#### Scenario: Сущность со слишком длинным идентификатором пропускается
|
||||
|
||||
- **WHEN** элемент покрытой секции несёт `id` длиннее предела
|
||||
- **THEN** сущность в результат разбора не попадает
|
||||
- **AND** факт учитывается тем же счётчиком, что и отсутствие `id`
|
||||
|
||||
#### Scenario: Длительность берётся из тела, а не из интервала
|
||||
|
||||
- **WHEN** тренировка несёт `duration` равный `91.746` при интервале
|
||||
`start`/`end` в 91 секунду
|
||||
- **THEN** длительность сущности равна `91.746`
|
||||
|
||||
#### Scenario: Тренировка без длительности сохраняется без неё
|
||||
|
||||
- **WHEN** тренировка не несёт `duration` либо оно не является числом
|
||||
- **THEN** сущность сохраняется, а её длительность остаётся незаполненной
|
||||
- **AND** нулём она MUST NOT становиться
|
||||
|
||||
#### Scenario: Нечитаемый конец тренировки не отбрасывает её
|
||||
|
||||
- **WHEN** тренировка несёт `end`, который не разбирается
|
||||
- **THEN** конец сущности равен её началу
|
||||
- **AND** исходное значение остаётся в содержимом дословно
|
||||
|
||||
#### Scenario: Незнакомое поле тренировки переживает разбор
|
||||
|
||||
- **WHEN** тренировка несёт поле, которого разбор не знает
|
||||
- **THEN** оно сохраняется в содержимом сущности дословно
|
||||
- **AND** разбор не завершается ошибкой
|
||||
|
||||
#### Scenario: Непокрытая секция с собственными id остаётся непокрытой
|
||||
|
||||
- **WHEN** тело содержит `data.ecg`
|
||||
- **THEN** `ecg` попадает в список непокрытых ключей
|
||||
- **AND** сущностей из неё разбор не отдаёт
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Диагностика разбора не несёт значений из тела
|
||||
|
||||
Сообщение об ошибке разбора MUST NOT содержать значений из тела доставки. Оно
|
||||
SHALL называть **тип** встреченного токена и смещение во входе — по смещению
|
||||
место находится в теле, лежащем в архиве, а значение из тела в логе не имеет
|
||||
права быть в принципе.
|
||||
|
||||
Тип SHALL называться словарём JSON (`object`, `array`, `string`, `number`,
|
||||
`bool`, `null`), а не именем типа языка реализации: имя типа для делимитера не
|
||||
говорит ничего — какая скобка встретилась вместо ожидаемой, из него не следует,
|
||||
— а сам делимитер принадлежит фиксированному набору и содержимого не раскрывает,
|
||||
поэтому печатается значением.
|
||||
|
||||
Смещение SHALL указывать на место **перед** виновным токеном и от длины его
|
||||
значения зависеть MUST NOT. Декодер сообщает позицию как конец последнего
|
||||
возвращённого токена, поэтому взятая после чтения она отличалась бы от начала
|
||||
проблемы ровно на длину значения — то есть на восемь мегабайт в том самом
|
||||
случае, ради которого требование написано, и обещание «место находится в теле»
|
||||
не выполнялось бы.
|
||||
|
||||
Это не стиль, а тот же инвариант, что уже записан для точек: данные о здоровье
|
||||
чувствительнее токенов, тела запросов пишутся только на `DEBUG` и с обрезкой.
|
||||
Подстановка токена целиком инвариант обходит: тело в 8 МиБ даёт текст ошибки в
|
||||
8 МиБ, который уходит атрибутом `error` на уровень `WARN` — то есть содержимое
|
||||
доставки оказывается в логе полностью и без обрезки.
|
||||
|
||||
Предел SHALL держаться самим сообщением, а не обрезкой на стороне
|
||||
логирующего: обрезка живёт в другом месте и о новой ошибке разбора не узнает.
|
||||
|
||||
Правило SHALL распространяться и на **чужие** причины: ошибка библиотеки разбора
|
||||
кладёт в текст литерал значения, поэтому причина, приходящая извне, обрезается по
|
||||
названной длине на границе. Тот же предел SHALL действовать на проверке формы
|
||||
конверта при приёме — она пользуется той же библиотекой, и её отказ логируется на
|
||||
`DEBUG`, где инвариант тоже требует обрезки.
|
||||
|
||||
#### Scenario: Огромное значение не доезжает до текста ошибки
|
||||
|
||||
- **WHEN** тело содержит на месте ожидаемого объекта строку в несколько
|
||||
мегабайт
|
||||
- **THEN** разбор завершается ошибкой
|
||||
- **AND** длина текста ошибки не зависит от длины этого значения
|
||||
- **AND** текст называет тип токена словарём JSON и смещение перед токеном
|
||||
- **AND** смещение не меняется, если то же значение сделать длиннее
|
||||
Reference in New Issue
Block a user