- Правило покрытия получило второй разряд (условный, как у точек), запрет вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и ряд из null больше не затирают маршрут. Победитель внутри доставки стал функцией множества версий — общим помощником с точками, — а провенанс поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину к прежнему содержимому. - Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает на старте; текст ошибки разбора не несёт значений из тела. - Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты: безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя был квадратичен по числу присланных версий одного ключа.
47 KiB
MODIFIED Requirements
Requirement: Замена версии сущности не теряет содержания
Сущность с собственным id SHALL замещаться целиком, а не сливаться по
полям: она приезжает повторно, пока источник её досчитывает. Замер на живом
архиве: одна тренировка приехала 26 раз в трёх различных содержимых — сперва
добавились stepCadence и stepCount вместе с изменившимся рядом
activeEnergy, затем при том же наборе полей досчитались totalEnergy и
basalEnergy.
Замещение MUST быть условным: приехавшая версия побеждает, если не теряет содержания сохранённой. Порядок разбора:
1. хеш канонического содержимого совпал → содержимое не пишется,
провенанс поднимается до
более поздней позиции журнала
2. содержание приехавшей покрывает сохранённую
и сверх того → приехавшая замещает целиком
3. приехавшая теряет содержание сохранённой → остаётся сохранённая,
счётчик + WARN
4. содержание сравнимо, наборы равны → версия из более поздней
доставки журнала
5. наборы несравнимы → остаётся сохранённая,
счётчик + WARN
Содержание сравнивается множествами ключей и формой их значений — но не значениями. Сравнение полноты, принятое для точек, здесь неприменимо: оно гасит отношение включения, когда значения общих содержательных ключей разошлись, а у сущности они расходятся всегда — источник её досчитывает. Проверено: сохранённая тренировка с маршрутом против приехавшей без маршрута даёт «надмножество» при неизменных значениях и «равенство» при изменившихся, то есть на живых данных защита не сработала бы вовсе, а тест на фикстуре с неизменёнными значениями остался бы зелёным. Условия «значения общих ключей совпали» здесь быть MUST NOT.
Покрытие SHALL проверяться четырьмя условиями, все — по верхнему уровню содержимого:
- каждый ключ сохранённой с непустым значением есть у приехавшей и тоже непуст;
- при равенстве множеств содержательных ключей — каждый ключ сохранённой, включая пустые, есть у приехавшей. Тот же второй разряд записан для точек, и с тем же условием: иначе ключ с пустым значением исчезает по жребию тай-брейка. Безусловным он быть MUST NOT — проверено оракулом: версия с пустым ключом и без маршрута оказывалась несравнимой с законным досчётом, у которого маршрут приехал, а этого ключа нет, и маршрут не доезжал НИКОГДА;
- форма значения не вырождается: где у сохранённой объект, у приехавшей MUST
быть объект; где массив — массив. Версия, подменившая объект или массив
скаляром, покрывающей быть MUST NOT — иначе «скелет» из скаляров и
null-ов той же длины признаётся равным настоящей тренировке и выигрывает тай-брейк журнала; - верхнеуровневый массив не теряет ни длины, ни содержательных элементов:
усечённый маршрут (три точки вместо 593) ключа не теряет, а маршрут из
[null,null,null]не теряет и длины — притом что маршрут это 95% содержимого тренировки. Досчёт ряды удлиняет, поэтому и укорачивание, и опустошение элементов — законные признаки «приехало меньше».
Содержательность элемента ряда SHALL определяться той же пустотой, что и
содержательность поля точки: null, пустая строка, ноль в любой записи, пустой
объект, пустой массив; false содержателен. Второй словарь пустоты в проекте
завёл бы два ответа на один вопрос. Цена этого выбора называется вслух: ряд из
настоящих нулей ([0,0,0]) считается лишённым содержания, поэтому версия с
таким рядом сохранённую не заместит. Ошибка направлена в безопасную сторону —
правило удерживает, а не затирает, — и событие видно счётчиком; наблюдённые ряды
HAE состоят из объектов, а не из чисел.
Условия 3 и 4 применяются к ключам, содержательным у сохранённой версии.
Ключ, содержания не несущий, проверяется только на присутствие (условие 2):
формы у пустоты нет, и требовать её сохранения означало бы отличать [] от 0
там, где ни то, ни другое ничего не несёт.
Предел правила называется вслух и не закрывается: сокращение внутри
элемента ряда (точка маршрута без altitude при непустом элементе и той же
длине) не ловится ничем, кроме сверки с телом в архиве.
Содержимое сущности, не разбирающееся как объект JSON, SHALL давать пустые множества ключей — то же правило, что для точки: такая версия проигрывает любой версии с содержанием и не загрязняет наблюдение о несравнимых наборах.
Единственная причина повторной присылки — доезжающий маршрут, то есть рост: обратного за 44 доставленные копии не случилось ни разу. Но восстановление требует пересборки всего журнала, поэтому событие делается наблюдаемым, а не необратимым.
Тай-брейк при равных наборах — позиция доставки в журнале (received_at, id),
а не порядок свёртки. «Побеждает приехавшая» было бы функцией порядка
свёртки, а он порядку журнала не равен: воркер сворачивает в порядке журнала
только среди видимых ему доставок и абсолютного порядка при конкурентных
приёмах не обещает. Доставка с более ранней меткой, свёрнутая позже, вернула бы
витрину к недосчитанной версии, и пересборка разошлась бы с живым приёмом молча,
в содержимом тренировки. Позиция журнала снимает это: исход зависит от журнала,
а не от того, кто раньше добрался до базы.
Ровно поэтому провенанс сущности SHALL обновляться и тогда, когда хеш совпал: сохранённая позиция журнала участвует в тай-брейке пункта 4, и если в ней осталась первая свёрнутая копия вместо победителя журнала, отложенная доставка вернёт витрину к прежнему содержимому — то есть живая витрина разойдётся с пересборкой. Обновление MUST касаться только провенанса; содержимое при совпавшем хеше не переписывается, счётчик записанных сущностей не растёт (он считает содержимое витрины, и его сравнимость с прежними замерами важнее учёта обновления), и метка изменения содержимого не двигается тоже: иначе она стала бы меткой касания строки и дребезжала бы двадцать шесть раз на неизменившейся тренировке, а потребитель запроса «что изменилось с момента X» получил бы шум, неотличимый от настоящего досчёта. Провенанс несёт собственную метку — времени приёма своей доставки, — и для тай-брейка её достаточно.
Обновление провенанса SHALL быть идемпотентным: равные позиции журнала (та же доставка, свёрнутая повторно) ничего не меняют.
Слово «провенанс» у сущности и у часового объекта означает разное, и это называется вслух: у объекта хранится доставка, создавшая его, и она не поднимается никогда; у сущности — доставка, чья версия лежит сейчас, и она поднимается до максимума по журналу среди версий с этим содержимым. Причина в том, что у объекта нет замещения версии целиком, а у сущности только оно и есть.
Чтение сохранённой версии, сравнение и запись результата SHALL идти одной транзакцией: хеш и провенанс, на которых держится весь тай-брейк, читаются там же, где пишется исход. Оптимистичное чтение до транзакции допустимо только с перепроверкой обоих внутри — иначе две конкурентные свёртки одной сущности прочитают одну и ту же старую позицию, обе решат «я позже», и победит та, что закоммитила последней: исход снова станет функцией порядка коммитов, а не журнала, причём молча.
Отличие от точки здесь содержательное: у точки на одних координатах законно
встречаются два разных измерения, и предпочитать позднее нет оснований — там
исход решает порядок канонических форм. У сущности id — идентичность одного
объекта HealthKit, и вторая версия есть тот же объект, пересчитанный источником;
тай-брейк по канонической форме заморозил бы тренировку на произвольной из
версий навсегда, вместе с недосчитанной энергией.
Версии одного ключа внутри одной доставки позициями не различаются, и
победитель среди них SHALL быть функцией множества версий, а не порядка
элементов массива: сперва отбрасываются строго покрытые кем-то из остальных,
среди оставшихся берётся минимум канонической формы. «Строго покрыта» означает
«покрыта другой версией и сама её не покрывает»: покрытие — предпорядок, две
версии могут покрывать друг друга взаимно, и отбрасывание всего покрытого
опустошило бы множество, потеряв обе. Порядок при этом обязан быть тотальным
до конца: при совпавших канонических формах решает минимум исходных байтов —
иначе победителем оказывается тот, кто стоял в массиве раньше, а порядок ключей
в JSON от HAE нестабилен, и в хранилище легли бы разные байты при одинаковом
содержимом. Попарная свёртка здесь
неверна ровно так же, как она была неверна для точек: покрытие — частичный
порядок, тай-брейк — тотальный, и вместе они дают нетранзитивное отношение
победы, при котором [A,B,C] и [B,C,A] дают разных победителей, а порядок
элементов в JSON-массиве нестабилен. Сворачиваться между собой такие версии
SHALL до сравнения с сохранённой.
Факт «в одном теле приехали две версии одного ключа с разным содержанием» SHALL считаться симметрично и тоже быть функцией множества: считаются кандидаты, чья каноническая форма отличается от формы победителя. Счётчик этот SHALL быть ОТДЕЛЬНЫМ от счётчика удержаний: две версии в одном теле содержания не теряют — победитель ложится в витрину целиком, — и одно число на два события отвечало бы ни на одно. На счётчик удержаний опирается единственный контроль того, что правило покрытия не стало слишком строгим; примесь делает его неотличимым от шума.
Версии с совпавшей канонической формой SHALL схлопываться ДО выбора победителя. Выбор квадратичен по числу кандидатов, а их число приходит из чужого тела; без схлопывания тело в пределах приёма занимает свёртку на часы. Отбор SHALL видеть отмену: иначе дедлайн свёртки, заведённый ровно против зависшей работы, не значит ничего. Побайтовое различие при совпавшей канонической форме событием MUST NOT считаться — порядок ключей в JSON от HAE нестабилен и дребезг последнего разряда double тоже, так что счётчик по байтам срабатывал бы на измеренной норме потока. Различие содержимого при совпадающих множествах ключей и длинах массивов считаться SHALL: сегодня ровно этот случай даёт ноль и молчащий счётчик.
Поля версий MUST NOT объединяться: несравнимые наборы (приехавшая принесла новые ключи и потеряла старые) разрешаются в пользу сохранённой и считаются тем же счётчиком. Объединение отвергнуто там же и по той же причине, что для точек: на живом потоке событие не наступало, и вместо реализации заведено наблюдение.
Исход SHALL быть функцией журнала в его порядке. Остаточный предел называется
вслух: сравнение сохранённой с приехавшей попарно — в витрине лежит победитель
прошлых слияний, а не все кандидаты истории, — поэтому при несравнимых наборах
(пункт 5) исход зависит от порядка проигрывания. Тот же предел есть у часового
объекта; пункты 1–4 от порядка свёртки не зависят, а пункт 5 сопровождается
счётчиком и WARN.
Scenario: Доехавший маршрут замещает тренировку без маршрута
- WHEN та же тренировка приезжает повторно, добавив
route - THEN в хранилище лежит версия с маршрутом
Scenario: Досчитанные значения при том же наборе полей побеждают
- WHEN та же тренировка приезжает повторно с тем же набором полей и изменившимися значениями, доставкой с более поздней позицией журнала
- THEN в хранилище лежит приехавшая версия
Scenario: Версия из более ранней доставки не откатывает витрину
- WHEN две доставки несут одну тренировку с равными наборами полей, и свёрнута сперва более поздняя по журналу, затем более ранняя
- THEN в хранилище лежит версия из более поздней доставки
- AND тот же исход даёт свёртка в обратном порядке
Scenario: Обеднённая версия сохранённую не затирает
- WHEN та же тренировка приезжает повторно без
route, который был у сохранённой, и с изменившимися значениями общих полей - THEN в хранилище остаётся сохранённая версия
- AND факт учитывается счётчиком и записью
WARNс идентификатором тренировки
Scenario: Усечённый маршрут сохранённый не затирает
- WHEN та же тренировка приезжает повторно с тем же набором полей, но
routeкороче сохранённого - THEN в хранилище остаётся сохранённая версия
- AND факт учитывается тем же счётчиком
Scenario: Маршрут из пустых элементов сохранённый не затирает
- WHEN та же тренировка приезжает повторно с
routeтой же длины, все элементы которого пусты (nullлибо пустой объект) - THEN в хранилище остаётся сохранённая версия с координатами маршрута
- AND факт учитывается тем же счётчиком
Scenario: Скелет из скаляров сохранённую тренировку не затирает
- WHEN та же тренировка приезжает повторно, где каждый вложенный объект
заменён числом, а каждый массив — массивом той же длины из
null - THEN в хранилище остаётся сохранённая версия
- AND факт учитывается тем же счётчиком
Scenario: Ключ с пустым значением не исчезает по жребию
- WHEN та же тренировка приезжает повторно без ключа, значение которого у сохранённой было пустым, при совпадающих содержательных ключах
- THEN в хранилище остаётся сохранённая версия
- AND факт учитывается тем же счётчиком удержаний
Scenario: Пустой ключ не запирает законный досчёт
- WHEN у сохранённой версии есть ключ с пустым значением, а приехавшая его не несёт, но приносит содержательный ключ, которого у сохранённой не было
- THEN приехавшая замещает сохранённую
- AND счётчик удержаний не растёт
Scenario: Две версии одной сущности в одном теле
- WHEN тело содержит два элемента секции с одним
id - THEN исход не зависит от их порядка в массиве
- AND счётчик различающихся версий тоже не зависит от их порядка
Scenario: Три версии одной сущности в одном теле
- WHEN тело содержит три элемента секции с одним
id, из которых один покрывает второй, а третий несравним с обоими - THEN победитель одинаков при любой перестановке этих трёх элементов
Scenario: Две версии разного содержания при равной длине массивов
- WHEN тело содержит два элемента секции с одним
id, содержимое которых различается, но множества ключей и длины верхнеуровневых массивов совпадают - THEN факт учитывается счётчиком различающихся версий
Scenario: Разные байты при совпавшей канонической форме событием не считаются
- WHEN тело содержит два элемента секции с одним
id, различающихся только порядком ключей либо записью числа - THEN счётчик различающихся версий не растёт
- AND в хранилище лежат одни и те же байты при любой перестановке элементов
Scenario: Повторная присылка обновляет провенанс
- WHEN та же сущность приезжает повторно с тем же содержимым доставкой, стоящей в журнале позже сохранённой
- THEN содержимое не переписывается
- AND провенанс сущности указывает на более позднюю доставку
Scenario: Отложенная доставка не возвращает витрину к прежнему содержимому
- WHEN журнал несёт содержимое A, затем B, затем снова A, и доставка с B свёрнута последней
- THEN содержимое сущности и отпечаток витрины совпадают со свёрткой того же журнала в его порядке
Scenario: Составной ключ не даёт коллизии отпечатка
- WHEN две витрины различаются только тем, где проходит граница между родом и идентификатором записи
- THEN отпечатки не совпадают
Scenario: Несравнимые наборы полей не объединяются
- WHEN приехавшая версия несёт содержательный ключ, которого нет у сохранённой, и теряет содержательный ключ, который у сохранённой есть
- THEN в хранилище остаётся сохранённая версия
- AND факт учитывается тем же счётчиком
Scenario: Повторная свёртка того же журнала состояния не меняет
- WHEN те же доставки сворачиваются повторно в том же порядке
- THEN содержимое сущностей не меняется
Requirement: Хранение сущностей с собственным идентификатором
Система SHALL хранить тренировки и записи секций с собственным id не
часовыми объектами, а по одной строке на сущность: у них есть естественный
ключ, они редки (за двое суток потока — две тренировки и две записи состояния
разума), и группировать их по часам незачем.
Единиц хранения две:
тренировка ключ id
заголовок колонками: имя, начало, конец, офсет зоны, длительность
запись ключ род секции + id
заголовок колонками: род, метка времени, офсет зоны
Сущность SHALL нести провенанс — идентификатор доставки, чья версия лежит
сейчас, и метку приёма этой доставки. Он нужен не отчётности: по нему
разрешается тай-брейк между версиями равной полноты (см. «Замена версии
сущности…»), и без него WARN об удержанной обеднённой версии не связать с
телом в архиве.
Длительность тренировки SHALL допускать отсутствие значения, отличимое от нуля: ноль — законная длительность, и потребитель, сложивший столбец, иначе не отличил бы «источник не прислал» от «измерено ноль».
Ключ записи SHALL быть парой род + id, а не одним id. Собственный id
наблюдался живьём только у stateOfMind, где он UUID HealthKit; форма
идентификатора остальных пяти секций не наблюдалась никем, и короткий
несквозной id в двух разных секциях затёр бы одну запись другой молча. Пара
стоит ноль: запросы к записям всегда идут с родом.
Содержимое сущности SHALL храниться дословно — теми же байтами, какими пришло, включая маршрут, внутренние ряды и сводки. Заголовок колонками существует ради выборки по времени и не является разбором содержимого: любая следующая колонка была бы решением за Apple о том, что в тренировке главное.
Ряд пульса внутри тренировки MUST лежать в её содержимом, а не в объектах
метрики heart_rate: это разные таблицы, и смешение задвоило бы ряд.
Сущности доставки SHALL записываться той же транзакцией, что и её точки. Доставка — единица свёртки; частичное состояние ломает инвариант «состояние пересобираемо», а наблюдение «секции не смешиваются в одной доставке» собрано за двое суток и основанием для второй транзакции не является.
Система SHALL хранить рядом с сущностью хеш её канонического содержимого и пропускать запись содержимого, если хеш не изменился. Тренировка переприсылается каждой доставкой автоматизации, пока не доедет маршрут: на живом архиве 44 доставленные копии дают три различных содержимых.
Сравнение SHALL начинаться с хеша, читаемого без содержимого сохранённой сущности: маршрут доходит до мегабайта, разжимать и канонизировать его на каждой из 44 копий не за чем.
Каноническая форма приехавшей сущности SHALL считаться один раз на версию и
до входа в транзакцию, а хеш SHALL браться из уже посчитанной формы. Внутри
транзакции канонизации приехавших версий быть MUST NOT: транзакция открывается
immediate, то есть блокирует запись, и повторяется до пяти раз при занятости
базы — измерено, что тело 40 МиБ даёт пик кучи 768 МиБ, а тело 63 МиБ удерживает
блокировку 5.019 с при busy_timeout 5000, после чего конкурентная доставка
исчерпывает повторы. Считать форму дважды (в хеше и в сравнении) система MUST
NOT: это ровно та же работа над теми же байтами.
Остаточный предел называется вслух: разбор сохранённой версии остаётся внутри транзакции — её содержимое читается оттуда же и только когда хеш разошёлся. Значит удержание блокировки по-прежнему пропорционально размеру сохранённой сущности, и класс отказа «конкурентный приём исчерпал повторы → 500 по доставке, тело которой уже в архиве» этим требованием не закрывается, а лишь становится различимым в логе. Закрыть его может только предел на размер сущности вместе с потоковым расчётом — отдельная задача.
Scenario: Тренировка хранится одной строкой с маршрутом
- WHEN приезжает тренировка с маршрутом
- THEN она хранится одной строкой, адресуемой своим
id - AND маршрут и внутренние ряды лежат в её содержимом дословно
Scenario: Ряд пульса тренировки не попадает в метрику
- WHEN тренировка несёт
heartRateData - THEN объектов метрики
heart_rateэта доставка не создаёт
Scenario: Записи разных родов с одинаковым id не сталкиваются
- WHEN две записи разных родов приезжают с одним и тем же
id - THEN в хранилище лежат обе
Scenario: Повторная присылка той же тренировки не пишет в базу
- WHEN приезжает тренировка, содержимое которой совпадает с сохранённым, и её доставка стоит в журнале не позже сохранённой
- THEN хеш совпадает и запись не выполняется
Scenario: Отказ посреди доставки не оставляет части сущностей
- WHEN свёртка доставки прерывается на середине
- THEN не записывается ни одна сущность этой доставки
Scenario: Каноническая форма сущности считается один раз
- WHEN доставка с сущностью сворачивается, и транзакция повторяется из-за занятости базы
- THEN каноническая форма приехавшей сущности не пересчитывается ни на повторе, ни отдельно от хеша
ADDED Requirements
Requirement: Открытие базы отказывает при схеме из будущего
Открытие витрины SHALL сверять версию схемы базы с версией, вшитой в бинарь, до наката миграций. Версия базы выше версии бинаря MUST быть отказом с указанием обеих, а не поводом мигрировать: прецедент уже записан для открытия только на чтение — «расхождение версий — отказ, а не повод мигрировать».
Без этого откат бинаря проходит молча: старый бинарь поверх новой схемы стартует успешно, незнакомые секции игнорирует и доставки за окно отката помечает разобранными — то есть ничто не намекает, что для этого окна нужна пересборка. Класс «молчание», и цена его растёт вместе с ретеншеном: после удаления тел окно становится невосстановимым.
Асимметрия относится только к открытию с накатом миграций: там версия базы ниже версии бинаря отказом быть MUST NOT — ради этого случая миграции и существуют. Открытие только на чтение сохраняет строгое равенство версий, как уже нормировано пересборкой: утилита, которой достаточно прочитать учёт, на базе старее бинаря читала бы колонки, которых там ещё нет. Ослабление этого отказа настоящим требованием запрещено.
В одно место SHALL выноситься чтение версии, а не сравнение: сравнивают эти два способа открытия по-разному, а читают одинаково. Отсутствие журнала миграций (новая база) SHALL означать версию 0, и распознаваться это MUST по структуре базы, а не по тексту ошибки драйвера — сообщения драйвера контрактом не являются, и это уже записанное правило проекта.
Читать версию система SHALL средствами того же инструмента миграций, которым их накатывает, если он это умеет: имя таблицы учёта, имя колонки и правило «максимум = текущая версия» принадлежат ему, и рукописная копия его приватной схемы разошлась бы при обновлении зависимости — причём не отказом, а тем, что страж перестал бы ловить. Если цена такого чтения неприемлема (например, оно требует записи на соединении только для чтения), копия допустима, но SHALL жить одной функцией с названной вслух причиной.
Эксплуатационная цена отказа называется вслух, потому что она реальна: сервис не
поднимется, а телефон шлёт непрерывно и молча, и доставка, не попавшая в архив,
в журнал не попадает вовсе. Выбор сделан так потому, что откат бинаря — действие
оператора, который в этот момент рядом и видит отказ сразу, а дыры плотных
метрик закрывают широкий и глубокий проходы синхронизации. Не закрывается ими
stateOfMind: у него доставки HAE единственный источник, и окно простоя для
него — потеря без возврата. Молчаливый старт при этом стоит дороже: он портит
витрину за всё окно отката, и узнать об этом неоткуда.
Scenario: Старый бинарь не открывает базу из будущего
- WHEN в журнале миграций базы стоит версия выше последней, вшитой в бинарь
- THEN открытие завершается отказом с указанием обеих версий
- AND миграции не накатываются
Scenario: Новая база открывается и мигрирует
- WHEN базы ещё нет либо журнал миграций пуст
- THEN открытие проходит и накатывает миграции до версии бинаря
Scenario: Открытие только на чтение остаётся строгим
- WHEN версия схемы базы ниже последней, вшитой в бинарь, и база открывается только на чтение
- THEN открытие завершается отказом с указанием обеих версий
Requirement: Пропущенные сущности видны в учётной записи доставки
Учётная запись доставки SHALL нести число сущностей, которые разбор пропустил:
без id, с непомерно длинным id, с неразбираемой меткой времени или не
разобравшихся как объект.
Причина не в отчётности. Ретеншен сырого архива решает «что потеряется, если
тело удалить», по базе, и сегодня получает ответ «терять нечего» ровно там,
где потеряна тренировка с маршрутом: сущность в витрину не попала, список
непокрытых секций пуст, статус parsed. Лог здесь не годится — он ротируется,
а решение об удалении тела необратимо.
Число SHALL замещаться целиком при каждой свёртке доставки, включая замещение нулём: иначе доставка, пропуски которой исчезли вместе с поумневшим разбором, осталась бы помеченной навсегда. Записываться оно SHALL в обоих исходах свёртки — и при успехе, и при отказе, если разбор успел досчитать, — тем же правилом, каким уже записывается список непокрытых секций.
«Не измерялось» SHALL быть отличимо от нуля, и на пути отказа тоже. Разбор, вернувший ошибку, отдаёт нулевые счётчики по построению, а не по измерению; записать этот ноль значило бы объявить проверенной доставку, содержимое которой никто не смотрел. Число SHALL записываться только когда разбор досчитал; во всех прочих исходах колонка MUST оставаться нетронутой — той же идиомой, какой уже сохраняется выведенный слой. Доставки, свёрнутые разбором, который пропусков не считал, значения не имеют, и подстановка нуля объявила бы их проверенными: ретеншен получил бы то самое ложное «терять нечего», ради которого счётчик и заводится, — только теперь с видом измерения. Поэтому колонка допускает отсутствие значения, миграция его не подставляет, а читатель, принимающий по счётчику необратимое решение, SHALL трактовать отсутствие как «не удалять». Замер на живом архиве (118 тел) даёт ноль пропусков всех классов, то есть исторический корпус ничего не потерял, — но «ничего не потерял по замеру» и «проверено этим разбором» это разные утверждения, и колонка обязана их различать.
Счётчик — производное от разбора поле: пересборка витрины SHALL начинать его пустым и переносить из журнала MUST NOT, иначе свежая витрина унаследует измерение прежнего разбора.
Статус разбора от пропуска сущности меняться MUST NOT: partial определён
списком непокрытых секций, и второй источник истины для него завёл бы ровно то
расхождение читателей, которое учёт частичного разбора запрещает явно.
Scenario: Пропущенная сущность видна в учёте доставки
- WHEN тело несёт покрытую секцию, один элемент которой не разобрался
- THEN число пропущенных сущностей у доставки больше нуля
- AND соседние сущности той же секции сохранены
Scenario: Пересвёртка без пропусков обнуляет счётчик
- WHEN доставка с ненулевым числом пропущенных сущностей сворачивается повторно разбором, который эти элементы понимает
- THEN число пропущенных сущностей у доставки равно нулю
Scenario: Доставка, свёрнутая до появления счётчика, отличима от нулевой
- WHEN доставка была свёрнута разбором, который пропусков не считал, и с тех пор не пересворачивалась
- THEN её число пропущенных сущностей отсутствует, а не равно нулю