- свёртка спрашивает журнал, встречалось ли имя строго раньше по паре (received_at, id), и пишет WARN с атрибутом uncovered_new; повторные молчат. Признак выводится, а не хранится — реестр был бы второй копией факта - добавлена подкоманда `healthlog uncovered`: перечень накопленного, чтение только на чтение, экранированные имена и названные границы носителя - синк документации: ADR о выводе новизны из журнала, две записи в журнал дефектов, два правила промоутом в конвенции, терминал оператора назван адресатом недоверенного входа
1684 lines
139 KiB
Markdown
1684 lines
139 KiB
Markdown
# storage Specification
|
||
|
||
## Purpose
|
||
TBD - created by archiving change razbor-metrik-v-obekty. Update Purpose after archive.
|
||
## Requirements
|
||
### Requirement: Идентичность точки по координатам
|
||
|
||
Система SHALL адресовать точку координатами
|
||
`метрика + слой + начало + конец`. У точки-измерения конец равен началу; у
|
||
точки-интервала — концу интервала. Ключ MUST быть одной формы для всех точек:
|
||
интервальная и точечная формы не встречаются вперемешку внутри одной метрики
|
||
одной доставки (проверено на всём корпусе), поэтому ветвление по «классу
|
||
метрики» не нужно и вводить его MUST NOT.
|
||
|
||
Поле `source` в ключ входить MUST NOT: оно нестабильно — то же измерение с тем
|
||
же значением приезжает то как `Apple Watch Ultra 3|iPad (Anton)`, то как
|
||
`Apple Watch Ultra 3`, потому что Health переосмысливает атрибуцию задним
|
||
числом.
|
||
|
||
Начало точки берётся из `start`, а при его отсутствии — из `date`; конец — из
|
||
`end`, а при его отсутствии — из начала. Измерено: `start`, когда он есть,
|
||
**всегда** совпадает с `date` (ноль исключений на 22 метриках), поэтому правило
|
||
не вводит второго источника метки — оно лишь закрывает случай, когда HAE
|
||
перестанет их дублировать.
|
||
|
||
Час объекта определяется по началу точки: интервал пересекает границы часов, и
|
||
любой другой выбор сделал бы принадлежность объекту зависящей от длительности.
|
||
|
||
Ключ по одной метке проверялся и отвергнут: он схлопывает записи сна. Измерено
|
||
на всех 94 доставках — 170 координат против 174 и **33 столкновения внутри
|
||
одной доставки**, где `received_at` общий, тай-брейк по нему неприменим в
|
||
принципе, и исход решал бы порядок элементов в JSON-массиве, а он нестабилен.
|
||
При этом разные интервалы под одной меткой всегда несут разное содержимое
|
||
(проверено по всем метрикам), то есть ключ с интервалом ничего не задваивает.
|
||
|
||
Идентичность по хешу содержимого проверялась и отвергнута: она задваивала
|
||
минутный слой целиком — 120 точек в часе вместо 60.
|
||
|
||
#### Scenario: Повторная доставка той же точки ничего не меняет
|
||
|
||
- **WHEN** точка с теми же координатами и тем же содержимым приезжает снова
|
||
- **THEN** хранилище не изменяется
|
||
|
||
#### Scenario: Смена источника не создаёт вторую точку
|
||
|
||
- **WHEN** точка с теми же координатами приезжает с другой строкой `source`
|
||
- **THEN** она остаётся одной точкой, а не превращается в две
|
||
|
||
#### Scenario: Записи с одной меткой и разными интервалами не схлопываются
|
||
|
||
- **WHEN** в доставке приходят точки `sleep_analysis` с одинаковым `date` и
|
||
разными парами `start`/`end`
|
||
- **THEN** каждая сохраняется отдельной точкой
|
||
|
||
#### Scenario: Повтор записи в следующей доставке не задваивает
|
||
|
||
- **WHEN** точка с тем же началом и концом приезжает следующей доставкой
|
||
- **THEN** она остаётся одной точкой
|
||
|
||
#### Scenario: Точка-измерение адресуется вырожденным интервалом
|
||
|
||
- **WHEN** точка не несёт `end`
|
||
- **THEN** её конец равен началу, и ключ имеет ту же форму, что у интервала
|
||
|
||
### Requirement: Разрешение столкновений по полноте
|
||
|
||
Когда по одним координатам приходят разные содержимые, система SHALL оставлять
|
||
**более полную** точку — ту, чьё множество ключей с непустым значением является
|
||
**строгим надмножеством** множества другой, — а не последнюю пришедшую. Иначе
|
||
бедная доставка стирает у богатой поля, которых сама не несёт.
|
||
|
||
Полнота SHALL сравниваться множествами, а не их размером. Число сравнимо
|
||
всегда, и потому счётчик даёт ответ там, где ответа нет: точка с пятью полями,
|
||
не несущими содержания, побеждала бы настоящее измерение с двумя полями и
|
||
стирала бы его безвозвратно.
|
||
|
||
Измерено (находка 49): настоящих столкновений 2 897 из 444 256 координат
|
||
(0.65%); из них 981 различаются набором полей — это и есть область правила
|
||
полноты, — 1 916 несут равные наборы и разные значения, где исход решает
|
||
тай-брейк, а несравнимых наборов ноль.
|
||
|
||
Перемер 2026-08-04 на выросшем корпусе (155 доставок, 460 995 координат,
|
||
настоящий ключ со слоем) уточнил соотношение и сделал тай-брейк главным
|
||
разрядом правила, а не крайним: спорных координат 80 129, полнота отбрасывает
|
||
кого-то в 981 из них (1,2%), остальные 79 148 (98,8%) уходят в тай-брейк.
|
||
Смена тай-брейка меняет исход на 75 494 координатах, из них 71 773 —
|
||
`basal_energy_burned` слоя `raw`, то есть посекундная развёртка HAE, которую
|
||
Read API суммировать и так не имеет права.
|
||
|
||
Пустым значением MUST считаться `null`, пустая строка, число, равное нулю (в
|
||
любой записи), пустой объект и пустой массив: поле без содержания не делает
|
||
точку полнее точки, где этого поля нет вовсе. Пустота MUST определяться по
|
||
разобранному значению, а не по байтам: `0`, `0.0`, `0e0`, `-0` и `{ }` — та же
|
||
пустота, что `0` и `{}`.
|
||
|
||
`false` пустотой MUST NOT считаться: для булева поля это одно из двух значений,
|
||
а не отсутствие содержания (`isIndoor: false` — тренировка на улице).
|
||
|
||
Поле `source` в множество не входит — оно нестабильно и переписывается задним
|
||
числом (находка 36), так что о полноте измерения ничего не говорит.
|
||
|
||
Содержимое, которое не разбирается как JSON-объект, SHALL нести **пустое
|
||
множество** ключей: так оно проигрывает любой точке с содержанием и не
|
||
загрязняет наблюдение о несравнимых наборах.
|
||
|
||
Надмножество побеждает только тогда, когда оно **несёт то же содержание**:
|
||
значения ключей, содержательных у обеих точек, MUST совпадать (с точностью до
|
||
канонической формы). Иначе точки несут разные измерения, и надмножество имён
|
||
о полноте не говорит ничего — такая пара MUST разрешаться как равнополная.
|
||
Без этого условия точка `{date, qty:0.001, p1:null, p2:null}` вытесняла бы
|
||
`{date, qty:72.5}`, то есть точка, где ни одно значение не измерение,
|
||
стирала бы измерение — ровно то, ради отрицания чего правило переписано.
|
||
|
||
Если множества ключей с непустым значением **равны и значения совпали**,
|
||
система SHALL сравнить множества **всех** ключей, кроме `source`, и оставить
|
||
строгое надмножество. Без этого разряда правило теряло бы поля там, где
|
||
заведено их беречь: точка `{date, qty:10, Min:0, Max:0}` и точка
|
||
`{date, qty:10}` несут одинаковое содержание, и `Min` с `Max` исчезли бы из
|
||
витрины по жребию. Несравнимость на этом разряде исходом MUST NOT быть:
|
||
лишние ключи там заведомо пусты, объединять в них нечего.
|
||
|
||
**Если полнота ответа не дала — равные множества с разными значениями либо
|
||
несравнимые множества, — победителем SHALL быть точка, пришедшая разбираемой
|
||
доставкой, а не лежавшая в объекте.** Байтовый порядок канонических форм на
|
||
этом разряде отвергнут замером: он берёт меньшее значение в 1 847 случаях из
|
||
1 912 (находка 49), то есть системно хранит версию, которую источник уже
|
||
пересчитал. Ценой этого выбора час `2026-08-03T07:00Z` метрики `step_count`
|
||
остался с недосчитанным значением, сверка слоёв объявила метрику мгновенной
|
||
против 23 согласных часов, и род ушёл в `unknown` — Read API потерял право
|
||
суммировать шаги.
|
||
|
||
Значение точки в правило входить MUST NOT: «брать бо́льшее» верно для
|
||
накопительных метрик и неверно для мгновенных, которые источник досчитывает
|
||
вниз. Род метрики в правило входить MUST NOT тоже — род есть функция витрины, а
|
||
правило слияния, читающее собственную выдачу, перестаёт быть функцией префикса
|
||
журнала.
|
||
|
||
**Столкновение ВНУТРИ одной доставки SHALL разрешаться порядком канонических
|
||
форм**: провенанс у таких точек общий, различать их нечем, а порядок элементов
|
||
в JSON-массиве от HAE нестабилен. Точка, канонически совпавшая с сохранённой,
|
||
SHALL считаться пришедшей — иначе сохранённый кандидат проигрывал бы соседу по
|
||
доставке, которого обязан был обойти по байтам, и «внутри доставки решают
|
||
байты» нарушалось бы ровно тогда, когда доставка ничего не изменила. Побеждает
|
||
при этом сохранённое содержимое дословно: хеш не двигается, объект не
|
||
переписывается. Тем же правилом схлопываются две точки одного тела с совпавшей
|
||
канонической формой — в витрине остаются байты **встреченной первой**. Выбор
|
||
между ними ненаблюдаем по построению: различие при совпавшей канонической форме
|
||
это порядок ключей или последний разряд double, и хеш содержимого объекта
|
||
считается по канонической форме, а не по байтам.
|
||
|
||
Цена нового тай-брейка называется вслух и ограничивается разрядом, на котором
|
||
он работает. **Когда значения общих содержательных ключей разошлись, полнота
|
||
ответа не даёт по построению** — «надмножество имён о полноте не говорит
|
||
ничего», см. выше, — и пришедшая точка побеждает, даже если сохранённая несла
|
||
сверх того ключи **без содержания** (`Min:0`, `Max:0`). Такие ключи по
|
||
определению пустоты этого же требования содержания не несут, и второй разряд их
|
||
бережёт только там, где содержание совпало. Прежний байтовый порядок сохранял
|
||
их случайно — по тому, что каноническая форма с ключом `Max` сортируется раньше
|
||
формы с одним `date`, — и рассчитывать на такую защиту было нельзя.
|
||
|
||
**Класс входа, на котором правило ведёт себя хуже прежнего, называется здесь.**
|
||
Две автоматизации, чьи наборы метрик пересекаются, наполняют одну координату
|
||
разными значениями (находка 14); прежний тай-брейк давал на ней устойчивый
|
||
исход, новый — чередование по последней доставке, то есть перезапись объекта и
|
||
`WARN` на каждой доставке. Защита остаётся операционной («наборы метрик между
|
||
автоматизациями не пересекать»), система её не проверяет, и это записано, чтобы
|
||
следующий разбор не искал причину заново.
|
||
|
||
**Победитель SHALL быть функцией множества точек координаты и их происхождения
|
||
(доставка или витрина), а не порядка элементов внутри доставки.** Попарная
|
||
свёртка этого не даёт: полнота — частичный порядок, тай-брейк — тотальный, и
|
||
вместе они образуют нетранзитивное отношение победы (A превосходит B по
|
||
полноте, B бьёт C тай-брейком, C бьёт A тай-брейком). При таком цикле повторная
|
||
свёртка одной и той же доставки меняет содержимое объекта. Поэтому система
|
||
SHALL отбросить кандидатов, превзойдённых по полноте кем-то другим, и выбрать
|
||
победителя среди оставшихся по тотальному порядку — сперва происхождение,
|
||
затем каноническая форма.
|
||
|
||
**Правило тем самым есть явная функция порядка журнала, и цена этого называется
|
||
вслух.** Функцией множества оно быть перестало: «пришедшая побеждает» не
|
||
коммутативно. Витрина остаётся свёрткой журнала только пока порядок свёртки
|
||
равен порядку журнала — требование, которое capability приёма обязана
|
||
обеспечивать, а capability пересборки обязана проверять оракулом. Восстановить
|
||
коммутативность «для чистоты» MUST NOT: это откатило бы починку молча.
|
||
|
||
Сравнение по `received_at` для тай-брейка не годится: у сохранённой точки нет
|
||
провенанса — ни времени приёма, ни идентификатора доставки, — и сравнивать не с
|
||
чем. Заводить его это изменение SHALL NOT: колонка провенанса на точку означала
|
||
бы смену формата содержимого объекта, миграцию и рост нижнего слоя, а
|
||
«пришедшая побеждает» даёт тот же исход, пока порядок свёртки равен порядку
|
||
журнала. Запрет этот **бюджетный, а не принципиальный**: он назван решением
|
||
владельца от 2026-08-04 и снимается тем же порядком. Если окно конкурентного
|
||
приёма закрыть на приёме не удастся, провенанс (на объект, не на точку)
|
||
остаётся единственным ходом, и спека обязана это допускать, а не запрещать
|
||
вечно.
|
||
|
||
Если множества **несравнимы** — каждое несёт ключ с непустым значением,
|
||
которого нет у другой, — система SHALL выбрать победителя тем же правилом, что
|
||
и при равных множествах, и MUST оставить наблюдение: счётчик в итоге разбора
|
||
доставки, координаты объекта и запись `WARN` без значений точки. Несравнимый
|
||
набор — частный случай столкновения: счётчик перезаписей растёт вместе с ним, а
|
||
координаты попадают в оба списка.
|
||
|
||
Объединять поля двух точек система SHALL NOT: на живом потоке несравнимые
|
||
наборы наблюдаются единицами (2 на 155 доставок), и реализация правила, которое
|
||
почти не срабатывает, стоила бы больше, чем счётчик, который скажет, если оно
|
||
станет массовым.
|
||
|
||
Победителем SHALL оставаться одна из пришедших точек **дословно**: правило
|
||
выбирает, а не конструирует. Каноническая форма существует только в момент
|
||
сравнения — вернуть её вместо исходных байт значило бы сохранить округлённое
|
||
число вместо присланного.
|
||
|
||
#### Scenario: Бедная точка не стирает поля богатой
|
||
|
||
- **WHEN** сохранена точка с `Avg`, `Min`, `Max` и `context`
|
||
- **AND** по тем же координатам приезжает точка только с `Avg`, `Min` и `Max`
|
||
- **THEN** сохранённая точка остаётся с `context`
|
||
|
||
#### Scenario: Поля без содержания полноты не добавляют
|
||
|
||
- **WHEN** сохранена точка с пятью полями, значения которых `0`, `{}` и `[]`
|
||
- **AND** по тем же координатам приезжает точка с `date` и ненулевым `qty`
|
||
- **THEN** остаётся точка с `date` и `qty`
|
||
|
||
#### Scenario: При равном содержании поля не теряются
|
||
|
||
- **WHEN** сохранена точка `{date, qty, Min:0, Max:0}`
|
||
- **AND** по тем же координатам приезжает точка `{date, qty}` с тем же `qty`
|
||
- **THEN** остаётся точка с `Min` и `Max`
|
||
|
||
#### Scenario: При разошедшихся значениях пустые ключи не удерживают точку
|
||
|
||
- **WHEN** сохранена точка `{date, qty:10, Min:0, Max:0}`
|
||
- **AND** по тем же координатам следующей доставкой приезжает точка
|
||
`{date, qty:12}`
|
||
- **THEN** остаётся пришедшая точка, а `Min` и `Max` в витрине не остаются
|
||
- **AND** счётчик перезаписей растёт
|
||
|
||
#### Scenario: Одинаково полные точки с разными значениями
|
||
|
||
- **GIVEN** по координатам сохранена точка предыдущей доставки
|
||
- **WHEN** следующей доставкой приезжает точка с тем же множеством ключей и
|
||
другим значением
|
||
- **THEN** в витрине остаётся пришедшая точка
|
||
|
||
#### Scenario: Одинаково полные точки внутри одной доставки
|
||
|
||
- **WHEN** в одном теле по одним координатам приезжают две точки с одинаковыми
|
||
множествами ключей и разными значениями
|
||
- **THEN** остаётся точка с меньшей канонической формой
|
||
- **AND** исход не зависит от порядка этих точек в массиве
|
||
|
||
#### Scenario: Повторная присылка сохранённого содержимого ничего не переписывает
|
||
|
||
- **GIVEN** по координатам сохранена точка
|
||
- **WHEN** следующей доставкой приезжает точка с той же канонической формой и
|
||
другими байтами
|
||
- **THEN** в витрине остаются сохранённые байты
|
||
- **AND** объект не переписывается
|
||
|
||
#### Scenario: Полнота сильнее происхождения
|
||
|
||
- **GIVEN** по координатам сохранена точка с `Avg`, `Min` и `Max`
|
||
- **WHEN** следующей доставкой приезжает точка только с `Avg` и теми же
|
||
значениями общих ключей
|
||
- **THEN** остаётся сохранённая точка
|
||
- **AND** счётчик удержаний в итоге разбора доставки растёт
|
||
|
||
#### Scenario: Несравнимые множества считаются, а не сливаются
|
||
|
||
- **WHEN** по одним координатам приходят две точки, каждая из которых несёт
|
||
ключ с непустым значением, которого нет у другой
|
||
- **THEN** остаётся ровно одна точка, выбранная тем же правилом
|
||
- **AND** счётчик несравнимых наборов в итоге разбора доставки растёт
|
||
- **AND** система пишет `WARN` с координатами объекта и без значений точки
|
||
|
||
#### Scenario: Содержимое, которое не разбирается в объект
|
||
|
||
- **WHEN** по координатам сталкиваются точка с непустыми полями и содержимое,
|
||
не разбирающееся как JSON-объект
|
||
- **THEN** остаётся точка с полями
|
||
- **AND** счётчик несравнимых наборов не растёт
|
||
|
||
Столкновением SHALL считаться расхождение **канонических форм**, а не байтов.
|
||
Байты нестабильны — ради этого канонизация и заведена: из 81 952 повторно
|
||
приехавших точек 67 534 различаются лишь порядком ключей, ещё 63% — последним
|
||
разрядом double. Побайтовое сравнение давало бы тысячи ложных срабатываний на
|
||
каждом глубоком проходе, и настоящий отказ правила стал бы неотличим от нормы.
|
||
|
||
#### Scenario: Столкновение с различием содержимого оставляет след
|
||
|
||
- **WHEN** по одним координатам сохраняется точка, каноническая форма которой
|
||
отличается от уже сохранённой
|
||
- **THEN** система пишет запись уровня `WARN` без значений точки
|
||
- **AND** запись несёт координаты объекта: метрику, слой и час
|
||
- **AND** увеличивает счётчик перезаписей в итоге разбора доставки
|
||
|
||
#### Scenario: Дребезг сериализации столкновением не считается
|
||
|
||
- **WHEN** та же точка приезжает с другим порядком ключей или отличаясь
|
||
последним разрядом числа
|
||
- **THEN** счётчик перезаписей не растёт и `WARN` не пишется
|
||
|
||
Без этого следа допущение «меньше полей не значит новее» не получит ни одного
|
||
наблюдения, а отказ правила будет неотличим от нормальной работы до сверки с
|
||
экспортом Apple — то есть месяцами.
|
||
|
||
### Requirement: Хранение часовыми объектами
|
||
|
||
Система SHALL хранить точки часовыми объектами с ключом
|
||
`метрика + слой + час (UTC)`. Содержимое объекта — сжатый gzip блоб; точки
|
||
внутри упорядочены по времени.
|
||
|
||
Объект SHALL нести **единицы измерения** метрики. Внутри точки их нет — они
|
||
живут на уровне метрики (проверено: поле `units` не встретилось ни в одной
|
||
точке за 89 доставок), поэтому дословное хранение точек их не сохраняет. Без
|
||
колонки единицы восстановимы только из архива, а для метрик, переставших
|
||
приходить, — теряются навсегда.
|
||
|
||
Объект SHALL нести границы содержимого (первая и последняя метка) и
|
||
идентификатор доставки, создавшей его. Первое нужно каталогу разрезов, чтобы
|
||
не разжимать каждый блоб ради диапазона; второе — провенанс для разбора
|
||
слияний.
|
||
|
||
Запись — чтение объекта, слияние точек, запись обратно. Точки из объекта
|
||
MUST NOT удаляться. Содержимое объекта SHALL сериализоваться без
|
||
HTML-экранирования: `&`, `<` и `>` внутри точки обязаны храниться теми же
|
||
байтами, какими пришли, иначе «точка хранится дословно» перестаёт быть правдой,
|
||
а сравнение с последующей доставкой той же точки промахивается навсегда.
|
||
|
||
Доставка SHALL сворачиваться **одной транзакцией**. Транзакция на объект давала
|
||
недетерминированное частичное состояние: обход групп рандомизирован, и при
|
||
отказе посреди доставки набор уже записанных объектов каждый раз другой
|
||
(измерено: восемь прогонов одной доставки — семь разных состояний). Это ломает
|
||
инвариант «состояние пересобираемо».
|
||
|
||
Единицы измерения MUST NOT переписываться молча: при расхождении сохранённых и
|
||
пришедших единиц остаётся сохранённое значение, факт учитывается счётчиком и
|
||
попадает в запись уровня `WARN`. Внутри точки единиц нет, и у ранее сохранённых
|
||
точек не остаётся ничего, по чему их единицы восстановимы.
|
||
|
||
#### Scenario: Отказ посреди доставки не оставляет части объектов
|
||
|
||
- **WHEN** свёртка доставки прерывается на середине
|
||
- **THEN** не записывается ни один объект этой доставки
|
||
|
||
#### Scenario: Смена единиц не переподписывает сохранённые точки
|
||
|
||
- **WHEN** в объект приезжают точки в единицах, отличных от сохранённых
|
||
- **THEN** единицы объекта остаются прежними
|
||
- **AND** факт учитывается счётчиком и записью `WARN`
|
||
|
||
#### Scenario: Точки за один час ложатся в один объект
|
||
|
||
- **WHEN** приходят точки одной метрики и слоя за один час UTC
|
||
- **THEN** они хранятся одним объектом
|
||
|
||
#### Scenario: Дозапись в существующий час
|
||
|
||
- **WHEN** приходят новые точки за уже существующий час
|
||
- **THEN** объект перечитывается, точки сливаются, объект записывается обратно
|
||
- **AND** ранее сохранённые точки остаются в объекте
|
||
|
||
### Requirement: Хеш как детектор изменений
|
||
|
||
Система SHALL хранить хеш канонической формы объекта и пропускать запись, если
|
||
хеш не изменился. Хеш — детектор, а не ключ.
|
||
|
||
Это то, что делает широкие проходы синхронизации дешёвыми: глубокий проход
|
||
переприсылает неделю, но почти все сравнения сходятся и записи не происходит.
|
||
|
||
#### Scenario: Повторная присылка того же часа не пишет в базу
|
||
|
||
- **WHEN** приезжает доставка, целиком повторяющая уже сохранённый час
|
||
- **THEN** хеш совпадает и запись не выполняется
|
||
|
||
### Requirement: Признак запечатанного часа
|
||
|
||
Система SHALL хранить признак `sealed` у часового объекта и SHALL реагировать
|
||
на изменение запечатанного объекта сигналом, а не отказом.
|
||
|
||
Правило перевода часа в `sealed` в этой дельте **не определяется**: порог
|
||
глубины досчёта ставится по наблюдениям, которых пока нет (наблюдалось до
|
||
22 минут). До появления правила признак остаётся невыставленным, и сценарий
|
||
ниже проверяется только явной установкой в тесте — это осознанная граница, а
|
||
не упущение.
|
||
|
||
#### Scenario: Изменение запечатанного часа
|
||
|
||
- **WHEN** приходят точки за час, помеченный `sealed`
|
||
- **THEN** система пишет запись уровня `WARN`
|
||
- **AND** данные всё равно сохраняются
|
||
|
||
### Requirement: Значения точек не попадают в логи
|
||
|
||
Данные о здоровье чувствительнее токенов. Система MUST NOT писать значения
|
||
точек, содержимое сущностей и тела доставок в записи лога уровня выше `DEBUG`.
|
||
|
||
Содержимое сущности здесь не менее чувствительно, чем значение точки, а местами
|
||
более: маршрут тренировки — это геотрек до дома, а `labels` и `associations`
|
||
записи состояния разума — эмоциональные метки. Разрешены **координаты**:
|
||
идентификатор и род сущности, метка времени, идентификатор доставки — они
|
||
описывают, что случилось, а не что измерено.
|
||
|
||
Непокрытые секции называются в логе **именами ключей**: имя секции — это форма
|
||
пакета, а не измерение. Содержимое секции в лог не попадает ни при каком уровне
|
||
выше `DEBUG`. Имена идут структурным атрибутом, а не склейкой в текст сообщения:
|
||
кодировщик экранирует управляющие символы, и имя из чужого тела не разрывает
|
||
построчный разбор логов. То же относится к идентификатору сущности: он приходит
|
||
из чужого тела и ограничен по длине при разборе.
|
||
|
||
Частичный разбор уровня записи не повышает: `partial` — установившееся состояние
|
||
половины потока (53 доставки из 118), и постоянный `WARN` обесценил бы уровень.
|
||
Повышает уровень другое, и оснований два:
|
||
|
||
- срабатывание границ списка: тело с сотнями секций или с именем длиннее предела
|
||
на HAE не похоже вовсе;
|
||
- **первая по журналу встреча имени непокрытой секции** — событие однократное за
|
||
всю жизнь имени, и правила его живут в capability наблюдения за непокрытыми
|
||
секциями. Здесь оно названо, чтобы перечень оснований оставался полным: иначе
|
||
следующий читатель снимет ветвь как незаказанную.
|
||
|
||
#### Scenario: Разбор доставки логируется без значений
|
||
|
||
- **WHEN** доставка разобрана
|
||
- **THEN** запись лога содержит счётчики (метрик, точек, объектов, сущностей) и
|
||
идентификатор доставки
|
||
- **AND** не содержит ни значений точек, ни имён устройств
|
||
|
||
#### Scenario: Удержанная обеднённая версия логируется координатами
|
||
|
||
- **WHEN** приехавшая версия сущности отклонена как теряющая содержание
|
||
- **THEN** запись `WARN` содержит идентификатор и род сущности
|
||
- **AND** не содержит ни точек маршрута, ни того, какие поля потерялись
|
||
|
||
#### Scenario: Непокрытые секции названы именами ключей
|
||
|
||
- **WHEN** доставка содержит непокрытую секцию
|
||
- **THEN** запись лога содержит имена непокрытых ключей отдельным атрибутом
|
||
- **AND** не содержит ничего из содержимого этих секций
|
||
- **AND** уровень записи из-за одной лишь частичности не повышается
|
||
|
||
#### Scenario: Границы списка сработали
|
||
|
||
- **WHEN** список непокрытых ключей усечён по числу имён или по длине имени
|
||
- **THEN** запись лога имеет уровень `WARN`
|
||
- **AND** содержит число отброшенных имён
|
||
|
||
#### Scenario: Имя непокрытой секции встречено впервые
|
||
|
||
- **WHEN** доставка принесла имя непокрытой секции, которого не было ни в одной
|
||
доставке раньше неё в журнале
|
||
- **THEN** запись лога имеет уровень `WARN`
|
||
- **AND** содержит имя отдельным атрибутом новых секций
|
||
|
||
### Requirement: Учёт частично разобранной доставки
|
||
|
||
Система SHALL отличать доставку, разобранную целиком, от доставки, в теле
|
||
которой остались непокрытые разбором секции. Доставка с непустым списком
|
||
непокрытых ключей MUST получать статус `partial`, а не `parsed`.
|
||
|
||
Статусы разбора:
|
||
|
||
```
|
||
pending этим разбором ещё не смотрели — или смотрели, но работа не сделана
|
||
по обстоятельствам (см. ниже)
|
||
parsed разобрано всё, что в теле было
|
||
partial разобрано покрытое; в теле остались непокрытые секции
|
||
failed разобрать не удалось, точек нет
|
||
```
|
||
|
||
Источник истины — список непокрытых ключей; статус производен от него и от
|
||
факта отказа, в порядке `failed` → `partial` → `parsed`. Приоритет назван явно,
|
||
чтобы читатели (ретеншен, статистика) спрашивали статус, а не сравнивали список
|
||
со строкой.
|
||
|
||
**Отказ обстоятельств статуса не меняет вовсе.** Отмена работы снаружи и
|
||
занятость базы дольше повторов транзакции означают «не сделано», а не «не
|
||
выходит»: доставка остаётся `pending` и будет свёрнута снова. Правило появилось
|
||
не из аккуратности — фоновая свёртка `failed` не подбирает никогда, и без этого
|
||
различения занятость базы (а с фоновой свёрткой конкуренция за неё штатная)
|
||
выводила бы доставку из очереди навсегда. Различение живёт **в одном месте**:
|
||
тот, кто пишет исход, и тот, кто классифицирует его в счётчики, спрашивают один
|
||
предикат.
|
||
|
||
Дедлайн самой свёртки к обстоятельствам MUST NOT относиться: доставка, не
|
||
уложившаяся в бюджет, не уложится в него и в следующий раз, а бесконечный повтор
|
||
заведомо безнадёжного — это очередь, которая не движется.
|
||
|
||
Отказы, случившиеся **до** чтения тела (учётной записи нет, соседний запрос не
|
||
прошёл), и отказ самой записи исхода статуса не меняют по другой причине —
|
||
записать его нечем. Доставка остаётся `pending`, что честно: этим разбором её не
|
||
досмотрели.
|
||
|
||
Список непокрытых ключей SHALL сохраняться рядом с доставкой — именами ключей,
|
||
без содержимого секций. Он же ответ на вопрос «что останется потерянным, если
|
||
тело удалить»: для `stateOfMind` доставки HAE единственный источник, в экспорте
|
||
Apple его нет (находка 46). Поэтому список MUST сохраняться и при отказе
|
||
разбора, если разбор успел его собрать: `failed` с непустым списком — законное
|
||
состояние.
|
||
|
||
Запись списка MUST замещать прежнее значение целиком, включая замещение пустым:
|
||
иначе доставка, все секции которой стали покрытыми, осталась бы `partial`
|
||
навсегда.
|
||
|
||
Список — снимок покрытия **на момент свёртки**. Задача, которая начинает
|
||
разбирать секцию, тем же изменением SHALL переводить `partial`-строки с этим
|
||
ключом в `pending`; ретеншену позволено смотреть на `partial` только при
|
||
соблюдении этого правила.
|
||
|
||
Статусы, поставленные разбором, который частичного исхода не различал, доверия
|
||
не заслуживают: под `parsed` у них лежат и полностью разобранные доставки, и
|
||
доставки без метрик вовсе. Такие строки MUST переводиться в `pending` — «этим
|
||
разбором ещё не смотрели». Число точек у них до пересвёртки остаётся прежним: оно
|
||
производно от объектов витрины, которые никуда не делись.
|
||
|
||
#### Scenario: Доставка с непокрытой секцией отмечается частичной
|
||
|
||
- **WHEN** разбор доставки вернул непустой список непокрытых ключей
|
||
- **THEN** `parse_status` доставки равен `partial`
|
||
- **AND** список непокрытых ключей сохранён вместе с доставкой
|
||
- **AND** точки покрытой секции сохранены как обычно
|
||
|
||
#### Scenario: Доставка без непокрытых секций остаётся `parsed`
|
||
|
||
- **WHEN** разбор доставки не дал непокрытых ключей
|
||
- **THEN** `parse_status` равен `parsed`
|
||
- **AND** сохранённый список непокрытых ключей пуст
|
||
|
||
#### Scenario: Отказ разбора сильнее частичности
|
||
|
||
- **WHEN** разбор доставки завершился ошибкой в самом разборе или в записи
|
||
точек
|
||
- **THEN** `parse_status` равен `failed`
|
||
- **AND** список непокрытых ключей сохранён, если разбор успел его собрать
|
||
|
||
#### Scenario: Занятая база доставку из очереди не выводит
|
||
|
||
- **WHEN** разбор не состоялся из-за занятости базы или отмены работы снаружи
|
||
- **THEN** `parse_status` остаётся `pending`
|
||
|
||
#### Scenario: Пересвёртка после того, как секция стала покрытой
|
||
|
||
- **WHEN** доставка со статусом `partial` сворачивается повторно разбором,
|
||
который эту секцию покрывает
|
||
- **THEN** `parse_status` становится `parsed`
|
||
- **AND** сохранённый список непокрытых ключей пуст
|
||
|
||
### 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** каноническая форма приехавшей сущности не пересчитывается ни на
|
||
повторе, ни отдельно от хеша
|
||
|
||
### Requirement: Замена версии сущности не теряет содержания
|
||
|
||
Сущность с собственным `id` SHALL замещаться **целиком**, а не сливаться по
|
||
полям: она приезжает повторно, пока источник её досчитывает. Замер на живом
|
||
архиве: одна тренировка приехала 26 раз в трёх различных содержимых — сперва
|
||
добавились `stepCadence` и `stepCount` вместе с изменившимся рядом
|
||
`activeEnergy`, затем при том же наборе полей досчитались `totalEnergy` и
|
||
`basalEnergy`.
|
||
|
||
Замещение MUST быть условным: приехавшая версия побеждает, **если не теряет
|
||
содержания** сохранённой. Порядок разбора:
|
||
|
||
```
|
||
1. хеш канонического содержимого совпал → содержимое не пишется,
|
||
провенанс поднимается до
|
||
более поздней позиции журнала
|
||
2. содержание приехавшей покрывает сохранённую
|
||
и сверх того → приехавшая замещает целиком
|
||
3. приехавшая теряет содержание сохранённой → остаётся сохранённая,
|
||
счётчик + WARN
|
||
4. содержание сравнимо, наборы равны → версия из более поздней
|
||
доставки журнала
|
||
5. наборы несравнимы → остаётся сохранённая,
|
||
счётчик + WARN
|
||
```
|
||
|
||
**Содержание сравнивается множествами ключей и формой их значений — но не
|
||
значениями.** Сравнение полноты, принятое для точек, здесь неприменимо: оно
|
||
гасит отношение включения, когда значения общих содержательных ключей
|
||
разошлись, а у сущности они расходятся **всегда** — источник её досчитывает.
|
||
Проверено: сохранённая тренировка с маршрутом против приехавшей без маршрута
|
||
даёт «надмножество» при неизменных значениях и «равенство» при изменившихся, то
|
||
есть на живых данных защита не сработала бы вовсе, а тест на фикстуре с
|
||
неизменёнными значениями остался бы зелёным. Условия «значения общих ключей
|
||
совпали» здесь быть MUST NOT.
|
||
|
||
Покрытие SHALL проверяться четырьмя условиями, все — по верхнему уровню
|
||
содержимого:
|
||
|
||
1. каждый ключ сохранённой **с непустым значением** есть у приехавшей и тоже
|
||
непуст;
|
||
2. **при равенстве множеств содержательных ключей** — каждый ключ сохранённой,
|
||
включая пустые, есть у приехавшей. Тот же второй разряд записан для точек, и
|
||
с тем же условием: иначе ключ с пустым значением исчезает по жребию
|
||
тай-брейка. Безусловным он быть MUST NOT — проверено оракулом: версия с
|
||
пустым ключом и без маршрута оказывалась несравнимой с законным досчётом, у
|
||
которого маршрут приехал, а этого ключа нет, и маршрут не доезжал НИКОГДА;
|
||
3. форма значения не вырождается: где у сохранённой объект, у приехавшей MUST
|
||
быть объект; где массив — массив. Версия, подменившая объект или массив
|
||
скаляром, покрывающей быть MUST NOT — иначе «скелет» из скаляров и
|
||
`null`-ов той же длины признаётся равным настоящей тренировке и выигрывает
|
||
тай-брейк журнала;
|
||
4. верхнеуровневый массив не теряет ни длины, ни **содержательных элементов**:
|
||
усечённый маршрут (три точки вместо 593) ключа не теряет, а маршрут из
|
||
`[null,null,null]` не теряет и длины — притом что маршрут это 95%
|
||
содержимого тренировки. Досчёт ряды удлиняет, поэтому и укорачивание, и
|
||
опустошение элементов — законные признаки «приехало меньше».
|
||
|
||
Содержательность элемента ряда SHALL определяться **той же пустотой**, что и
|
||
содержательность поля точки: `null`, пустая строка, ноль в любой записи, пустой
|
||
объект, пустой массив; `false` содержателен. Второй словарь пустоты в проекте
|
||
завёл бы два ответа на один вопрос. Цена этого выбора называется вслух: ряд из
|
||
настоящих нулей (`[0,0,0]`) считается лишённым содержания, поэтому версия с
|
||
таким рядом сохранённую не заместит. Ошибка направлена в безопасную сторону —
|
||
правило удерживает, а не затирает, — и событие видно счётчиком; наблюдённые ряды
|
||
HAE состоят из объектов, а не из чисел.
|
||
|
||
Условия 3 и 4 применяются к ключам, содержательным у сохранённой версии.
|
||
Ключ, содержания не несущий, проверяется только на присутствие (условие 2):
|
||
формы у пустоты нет, и требовать её сохранения означало бы отличать `[]` от `0`
|
||
там, где ни то, ни другое ничего не несёт.
|
||
|
||
Предел правила называется вслух и не закрывается: сокращение **внутри**
|
||
элемента ряда (точка маршрута без `altitude` при непустом элементе и той же
|
||
длине) не ловится ничем, кроме сверки с телом в архиве.
|
||
|
||
Содержимое сущности, не разбирающееся как объект JSON, SHALL давать пустые
|
||
множества ключей — то же правило, что для точки: такая версия проигрывает любой
|
||
версии с содержанием и не загрязняет наблюдение о несравнимых наборах.
|
||
|
||
Единственная причина повторной присылки — доезжающий маршрут, то есть рост:
|
||
обратного за 44 доставленные копии не случилось ни разу. Но восстановление
|
||
требует пересборки всего журнала, поэтому событие делается наблюдаемым, а не
|
||
необратимым.
|
||
|
||
**Тай-брейк при равных наборах — позиция доставки в журнале `(received_at, id)`,
|
||
а не порядок свёртки.** Порядок свёртки приведён к порядку журнала требованием
|
||
capability приёма, но равенство это неполное: строка учёта становится видимой
|
||
воркеру только после записи тела, и при конкурентном приёме остаётся окно, в
|
||
котором доставка с более ранней меткой сворачивается позже. Она вернула бы
|
||
витрину к недосчитанной версии, и пересборка разошлась бы с живым приёмом молча,
|
||
в содержимом тренировки. Хранимая позиция журнала снимает это целиком: исход
|
||
зависит от журнала, а не от того, кто раньше добрался до базы, — то есть у
|
||
сущности гарантия строго сильнее, чем у точки, и держится она на колонке
|
||
провенанса, которой у точки нет.
|
||
|
||
Ровно поэтому **провенанс сущности SHALL обновляться и тогда, когда хеш
|
||
совпал**: сохранённая позиция журнала участвует в тай-брейке пункта 4, и если
|
||
в ней осталась первая свёрнутая копия вместо победителя журнала, отложенная
|
||
доставка вернёт витрину к прежнему содержимому — то есть живая витрина
|
||
разойдётся с пересборкой. Обновление MUST касаться **только** провенанса;
|
||
содержимое при совпавшем хеше не переписывается, счётчик записанных сущностей
|
||
не растёт (он считает содержимое витрины, и его сравнимость с прежними замерами
|
||
важнее учёта обновления), и метка изменения содержимого не двигается тоже:
|
||
иначе она стала бы меткой касания строки и дребезжала бы двадцать шесть раз на
|
||
неизменившейся тренировке, а потребитель запроса «что изменилось с момента X»
|
||
получил бы шум, неотличимый от настоящего досчёта. Провенанс несёт собственную
|
||
метку — времени приёма своей доставки, — и для тай-брейка её достаточно.
|
||
|
||
Обновление провенанса SHALL быть идемпотентным: равные позиции журнала (та же
|
||
доставка, свёрнутая повторно) ничего не меняют.
|
||
|
||
Слово «провенанс» у сущности и у часового объекта означает **разное**, и это
|
||
называется вслух: у объекта хранится доставка, **создавшая** его, и она не
|
||
поднимается никогда; у сущности — доставка, **чья версия лежит сейчас**, и она
|
||
поднимается до максимума по журналу среди версий с этим содержимым. Причина в
|
||
том, что у объекта нет замещения версии целиком, а у сущности только оно и есть.
|
||
|
||
Чтение сохранённой версии, сравнение и запись результата SHALL идти **одной
|
||
транзакцией**: хеш и провенанс, на которых держится весь тай-брейк, читаются
|
||
там же, где пишется исход. Оптимистичное чтение до транзакции допустимо только
|
||
с перепроверкой обоих внутри — иначе две конкурентные свёртки одной сущности
|
||
прочитают одну и ту же старую позицию, обе решат «я позже», и победит та, что
|
||
закоммитила последней: исход снова станет функцией порядка коммитов, а не
|
||
журнала, причём молча.
|
||
|
||
Отличие от точки — в **механизме**, а не в намерении, и критерий выбора между
|
||
ними записан здесь, чтобы третья единица хранения не открывала спор заново. Обе
|
||
предпочитают позднюю версию: у сущности — по хранимой позиции журнала, у точки —
|
||
по происхождению кандидата (пришла доставкой или лежала в объекте). Различает их
|
||
одно: у сущности есть колонка провенанса, у точки её нет и рамка решения
|
||
владельца заводить её запретила. Отсюда и разная сила гарантии, названная выше.
|
||
Порядок канонических форм у обеих остался тем же и там же — тай-брейком
|
||
**внутри одной доставки**, где провенанс общий и различать нечем. Тай-брейк по
|
||
канонической форме между доставками заморозил бы тренировку на произвольной из
|
||
версий навсегда, вместе с недосчитанной энергией, а у точки — систематически
|
||
хранил бы меньшее значение (находка 49).
|
||
|
||
Версии одного ключа **внутри одной доставки** позициями не различаются, и
|
||
победитель среди них 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 включать тренировки и записи наравне с часовыми
|
||
объектами: он единственный оракул сходимости пересборки, и отпечаток одних
|
||
объектов давал бы «состояние сошлось» при разъехавшихся тренировках — то есть
|
||
ломался бы молча ровно тем изменением, которое добавляет данные.
|
||
|
||
В отпечаток идут координаты сущности и хеш её содержимого. Значений он
|
||
раскрывать MUST NOT — как и для точек.
|
||
|
||
Порядок обхода SHALL быть детерминированным и заданным запросом, а не порядком
|
||
строк в файле базы. Строки разных разделов SHALL различаться константным
|
||
признаком раздела: без него строка одного раздела может совпасть со строкой
|
||
другого, и два разных состояния дали бы один отпечаток. По той же причине
|
||
составной ключ SHALL идти в отпечаток **отдельными полями с собственными
|
||
длинами**, а не склейкой: склейка выполняется до взятия длины, и пара
|
||
(`a`, `b/c`) даёт ту же строку, что (`a/b`, `c`).
|
||
|
||
Границу оракула стоит назвать вслух: в отпечаток идут координаты и хеш
|
||
содержимого, а **колонки заголовка** сущности (имя, конец интервала, офсет,
|
||
длительность) — нет. Они производны от содержимого, поэтому их расхождение
|
||
означает изменившийся код извлечения заголовка, а не разъехавшееся состояние; но
|
||
«отпечатки совпали» не является утверждением о них.
|
||
|
||
Все разделы SHALL читаться **одним снимком** базы. Отпечаток рабочей витрины
|
||
снимается под живым приёмом, и запросы вне общей транзакции чтения дали бы смесь
|
||
«объекты до» и «тренировки после» — то есть ложное расхождение у единственного
|
||
оракула сходимости.
|
||
|
||
#### Scenario: Расхождение тренировок видно в отпечатке
|
||
|
||
- **WHEN** две витрины совпадают по часовым объектам, но содержимое одной
|
||
тренировки различается
|
||
- **THEN** отпечатки не совпадают
|
||
|
||
#### Scenario: Отпечаток одинаков при одинаковом содержимом
|
||
|
||
- **WHEN** та же витрина собрана повторно из того же журнала
|
||
- **THEN** отпечаток совпадает
|
||
|
||
#### Scenario: Запись во время снятия отпечатка не смешивает разделы
|
||
|
||
- **WHEN** отпечаток снимается, а параллельно коммитится свёртка
|
||
- **THEN** отпечаток отражает одно состояние базы, а не смесь снимков
|
||
|
||
### 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** её число пропущенных сущностей отсутствует, а не равно нулю
|
||
|
||
### Requirement: Перечисление разрезов не читает содержимое объектов
|
||
|
||
Хранилище SHALL отвечать на вопрос «какие слои есть у метрики, за какой период и
|
||
сколько в них точек» по учётным колонкам объекта, не разжимая `payload` и не
|
||
затрагивая страниц с содержимым. Тот же запрет действует на поиск часов, за
|
||
которые у метрики есть объекты сразу в двух слоях.
|
||
|
||
Запрет ограничен именно этими двумя выборками. Измерение рода обязано прочитать
|
||
значения точек, то есть разжать содержимое объектов окна, и требование его не
|
||
касается — иначе оно запрещало бы то, ради чего каталог существует.
|
||
|
||
Причина в форме таблицы: `bucket` объявлена `WITHOUT ROWID`, то есть строка
|
||
целиком, вместе со сжатым содержимым, живёт в дереве первичного ключа. Обход
|
||
всех строк ради агрегата тащил бы за собой страницы содержимого — при 260 тысячах
|
||
объектов за год это сотни мегабайт на каждый запрос каталога, притом что сам
|
||
ответ несёт три десятка строк.
|
||
|
||
Поэтому колонки, по которым отвечают эти выборки, MUST быть покрыты индексом, и
|
||
новая колонка, попадающая в ответ каталога, входит в него тем же изменением.
|
||
|
||
#### Scenario: Разрезы метрики за длинную историю
|
||
|
||
- **GIVEN** в витрине объекты за многие месяцы
|
||
- **WHEN** запрашиваются слои метрики с границами и числом точек
|
||
- **THEN** запрос отвечает по индексу, не читая содержимого объектов
|
||
|
||
#### Scenario: Общие часы двух слоёв
|
||
|
||
- **WHEN** запрашиваются самые свежие часы, за которые у метрики есть объекты и
|
||
в минутном, и в часовом слое
|
||
- **THEN** запрос отвечает по индексу и читает не больше запрошенного числа
|
||
часов
|
||
|
||
### Requirement: Объекты перечисленных часов читаются пакетом
|
||
|
||
Хранилище SHALL уметь отдать объекты двух слоёв за перечисленные часы одной
|
||
метрики **одним запросом**, а не по объекту за раз.
|
||
|
||
Чтение по одному даёт число обращений, растущее вместе с окном измерения, и
|
||
делает каждое обращение собственной транзакцией — то есть ответ, собранный из
|
||
разных снимков витрины под непрерывным приёмом.
|
||
|
||
#### Scenario: Окно из многих часов
|
||
|
||
- **GIVEN** запрошены объекты двух слоёв за сорок восемь часов
|
||
- **WHEN** выполняется выборка
|
||
- **THEN** число обращений к базе не зависит от числа часов
|
||
|
||
### Requirement: Хранилище отдаёт версию витрины
|
||
|
||
Система SHALL отдавать **версию витрины** — метку, которая MUST меняться при
|
||
любом коммите в базу и MUST NOT меняться, пока коммитов не было. Метка
|
||
предназначена условному запросу читающих маршрутов: равные метки означают, что
|
||
между их снятием в базу никто ничего не записал.
|
||
|
||
Метка MUST быть парой «поколение + счётчик». Счётчик — `PRAGMA data_version`,
|
||
поколение — идентификатор, выданный тому соединению, с которого счётчик
|
||
читается. Монотонной метка не является и сравнению на «новее» не подлежит:
|
||
гарантируется только неравенство.
|
||
|
||
Обе части обязательны, и каждая закрывает измеренный отказ:
|
||
|
||
- **счётчик несравним между соединениями.** На одном и том же состоянии базы
|
||
два соединения одного пула отвечают разными числами, а любое свежее
|
||
соединение отвечает одним и тем же значением независимо от содержимого базы.
|
||
Поэтому счётчик MUST читаться с одного закреплённого соединения, которое
|
||
ничем другим не занято: собственная запись соединения его версию не двигает,
|
||
и щуп, участвующий в записи, молчал бы о собственных изменениях.
|
||
- **счётчик не переживает переоткрытия.** После рестарта он начинается заново,
|
||
поэтому одно и то же значение до и после означает разные состояния витрины.
|
||
Без поколения клиент со старой меткой получал бы «не изменилось» на
|
||
изменившиеся данные — единственный по-настоящему опасный исход условного
|
||
запроса.
|
||
|
||
Соединение-щуп MUST NOT удерживать открытую читающую транзакцию между снятиями
|
||
версии: каждое снятие завершается до возврата. Иначе щуп — единственное
|
||
долгоживущее соединение процесса — становится тем самым вечным читателем,
|
||
против которого заведён чекпойнт, и версия витрины отменяет обслуживание
|
||
журнала при полностью исправном обслуживании.
|
||
|
||
Поколение MUST меняться всякий раз, когда соединение-щуп создаётся заново.
|
||
Переиспользовать поколение MUST NOT: это ровно тот случай, ради которого оно
|
||
заведено.
|
||
|
||
**Непригодность щупа — узкий класс, а не любая ошибка.** Пересоздание
|
||
допускается только при отказе, означающем закрытое соединение; отмена запроса
|
||
клиентом, занятость базы и прочие обстоятельства (то, что проект уже отличает
|
||
предикатом «не сделано» против «не выходит») поколение менять MUST NOT.
|
||
Измерено: отмена контекста запроса щуп не убивает — следующий запрос на нём
|
||
проходит. Считай система смертью щупа любую ошибку, каждый оборванный клиентом
|
||
запрос обнулял бы метки всех потребителей, то есть механизм схлопывался бы под
|
||
той самой нагрузкой, ради которой заведён.
|
||
|
||
Закрытие хранилища MUST освобождать щуп раньше пула и MUST исключать его
|
||
пересоздание после закрытия. Закреплённое соединение переживает закрытие пула
|
||
(измерено), а SQLite делает финальный чекпойнт только при закрытии последнего
|
||
соединения: забытый щуп оставляет рядом с базой неразобранный `-wal`, и
|
||
пересборка, переносящая один файл базы, теряет хвост записей молча.
|
||
|
||
#### Scenario: Витрина не менялась
|
||
|
||
- **GIVEN** после первого запроса версии в базу никто не писал
|
||
- **WHEN** версия запрашивается второй раз
|
||
- **THEN** обе версии совпадают
|
||
|
||
#### Scenario: Свёртка записала объект
|
||
|
||
- **GIVEN** версия витрины снята
|
||
- **WHEN** фоновая свёртка закоммитила изменения в витрину
|
||
- **THEN** следующая снятая версия отличается от прежней
|
||
|
||
#### Scenario: База переоткрыта
|
||
|
||
- **GIVEN** версия витрины снята, база закрыта и открыта заново
|
||
- **WHEN** версия снимается снова на том же файле
|
||
- **THEN** она отличается от снятой до переоткрытия
|
||
|
||
#### Scenario: Соединение-щуп стало непригодным
|
||
|
||
- **GIVEN** соединение, с которого читается счётчик, закрыто
|
||
- **WHEN** версия запрашивается снова
|
||
- **THEN** запрос отвечает версией НОВОГО поколения, а не отказом
|
||
|
||
#### Scenario: Запрос версии оборван клиентом
|
||
|
||
- **GIVEN** запрос версии отменён контекстом
|
||
- **WHEN** версия запрашивается следующим запросом
|
||
- **THEN** поколение остаётся прежним
|
||
|
||
#### Scenario: Щуп не мешает разбирать журнал
|
||
|
||
- **GIVEN** версия снималась много раз подряд
|
||
- **WHEN** выполняется пассивный чекпойнт и других читателей нет
|
||
- **THEN** журнал перенесён целиком
|
||
|
||
#### Scenario: Хранилище закрыто
|
||
|
||
- **GIVEN** хранилище закрыто
|
||
- **WHEN** запрашивается версия витрины
|
||
- **THEN** запрос отказывает и нового соединения к базе не открывает, а рядом с
|
||
базой не остаётся файла журнала
|
||
|
||
### Requirement: Чтение подписывается версией только целиком
|
||
|
||
Система SHALL снимать версию витрины **до и после** чтения, которое ею
|
||
подписывается, и SHALL отдавать версию, только если обе пробы совпали. При
|
||
расхождении версии нет, и это не отказ: ответ уходит полным, просто без метки.
|
||
|
||
Версия, снятая ПОСЛЕ чтения, MUST NOT выставляться на его результате: она
|
||
пометила бы устаревший снимок свежим номером и заперла бы клиента на нём
|
||
навсегда — единственный по-настоящему опасный исход всей конструкции. Версия,
|
||
снятая только ДО, допускает два разных ответа под одной меткой.
|
||
|
||
Правило MUST существовать в одном экземпляре: Read API точек и MCP заявлены
|
||
потребителями той же машинерии, и вторая её реализация «по образцу»
|
||
отличалась бы от первой ровно на этот порядок — а тест первой этого не
|
||
увидел бы.
|
||
|
||
Отказ пробы версией не является и чтение не отменяет: маршрут деградирует до
|
||
полного ответа, а не до отказа.
|
||
|
||
#### Scenario: Витрина стояла всё время чтения
|
||
|
||
- **WHEN** чтение выполнено и обе пробы дали одну версию
|
||
- **THEN** версия отдана
|
||
|
||
#### Scenario: Витрина изменилась во время чтения
|
||
|
||
- **GIVEN** между пробами в базу закоммитили
|
||
- **THEN** версии нет, а результат чтения отдан целиком
|
||
|
||
#### Scenario: Само чтение отказало
|
||
|
||
- **WHEN** чтение вернуло ошибку
|
||
- **THEN** ошибка отдана вызывающему, а не подменена отсутствием версии
|
||
|
||
### Requirement: Журнал WAL разбирается по таймеру, и его непродвижение видно
|
||
|
||
Система SHALL выполнять `PRAGMA wal_checkpoint(PASSIVE)` **раз в минуту**, пока
|
||
сервис работает. Автоматический чекпойнт SQLite MUST NOT считаться достаточным:
|
||
он срабатывает по концу записи, а поток пачечный — журнал, раздутый всплеском,
|
||
иначе остаётся неразобранным до следующей доставки, и ночью это часы.
|
||
|
||
Режим MUST быть `PASSIVE`. `TRUNCATE` и `RESTART` применять MUST NOT: они
|
||
двигают счётчик версии витрины, то есть каждый тик обнулял бы условный запрос у
|
||
всех потребителей, а `TRUNCATE` вдобавок ждёт читателей.
|
||
|
||
Пассивный чекпойнт не идёт дальше снимка самого старого активного читателя и
|
||
**ошибки при этом не возвращает**: измерено `busy=0` при 6256 страницах в
|
||
журнале и 5 перенесённых. Поэтому система SHALL считать признаком беды пару
|
||
чисел — страниц в журнале больше **16384** (64 МиБ при странице в 4 КиБ) **и**
|
||
перенесено меньше, чем лежало, — и MUST сообщать об этом владельцу уровнем
|
||
`WARN`. Флаг занятости признаком «не продвинулись» служить MUST NOT: он молчит
|
||
ровно в измеренном случае удерживаемого читателя.
|
||
|
||
**Зато флаг занятости выражает другое, и это MUST читаться: исход не измерен.**
|
||
Не взяв блокировку чекпойнта, SQLite отдаёт `busy=1` и **`-1` вместо обоих
|
||
чисел** — измерено, 1492 таких тика из 5502 при писателе и чекпойнте в цикле.
|
||
Сравнивать `-1` на шкале страниц MUST NOT: `-1 >= -1` истинно, то есть
|
||
незамеренный тик читался бы как «журнал разобран целиком», владельцу уходила бы
|
||
строка о выздоровлении посреди болезни, а подавитель повторов сбрасывался бы —
|
||
и вместо задуманного молчания получалась бы пара строк в минуту. Незамеренный
|
||
исход MUST не менять ни объявленного состояния, ни накопленного о нём.
|
||
|
||
Размер страницы MUST браться у самой базы, а не предполагаться: он фиксируется
|
||
при создании файла, и база, созданная чужим инструментом, сместила бы порог в
|
||
разы. Неизвестен — признак молчит.
|
||
|
||
Порог MUST быть выражен через ту же величину, что и предел файла журнала: это
|
||
одно число в двух ролях (предел возвращает файл, порог сообщает, что вернуть
|
||
его не выходит), и двумя разошедшимися константами признак стал бы либо
|
||
недостижимым, либо шумным — молча.
|
||
|
||
**Строка о непродвижении не пишется на каждый тик.** Признак заведён ради
|
||
состояния, которое само не проходит (вечный читатель живёт до конца процесса), а
|
||
строка в минуту дала бы 1440 одинаковых записей в сутки. Система SHALL сообщать
|
||
о входе в состояние и повторять, только когда журнал заметно вырос; возврат к
|
||
норме MUST быть отдельным событием — молчание иначе неотличимо от «сервис
|
||
перестал проверять».
|
||
|
||
Отказ чекпойнта MUST NOT прекращать цикл и MUST быть виден записью лога:
|
||
обслуживание, умершее от временного отказа базы, молча перестало бы разбирать
|
||
журнал до конца жизни процесса. Отмена контекста отказом при этом не является.
|
||
|
||
Файл журнала MUST иметь названный предел (`journal_size_limit`, 64 МиБ).
|
||
Предел **роста этим не даётся, и это сказано вслух**: измерено — под
|
||
удерживаемым читателем файл вырос до 51 МБ при пределе 8 МиБ, и успешный
|
||
чекпойнт его не укоротил; усечение делает первая запись после полного
|
||
чекпойнта. Пока читатель держит снимок, журнал растёт, и единственный исход —
|
||
`WARN` владельцу.
|
||
|
||
#### Scenario: Журнал разбирается в тишине
|
||
|
||
- **GIVEN** доставок нет, а в журнале остались неразобранные страницы
|
||
- **WHEN** проходит период чекпойнта
|
||
- **THEN** страницы перенесены в базу без единой новой записи
|
||
|
||
#### Scenario: Читатель держит снимок
|
||
|
||
- **GIVEN** идёт запись, и читающая транзакция удерживает старый снимок
|
||
- **WHEN** выполняется пассивный чекпойнт
|
||
- **THEN** он завершается без ошибки, переносит меньше, чем лежит в журнале, и
|
||
флаг занятости остаётся снятым
|
||
|
||
#### Scenario: Чекпойнт не взял блокировку
|
||
|
||
- **GIVEN** о непродвижении журнала уже сказано
|
||
- **WHEN** очередной чекпойнт возвращает признак занятости и `-1` вместо чисел
|
||
- **THEN** ни строки о выздоровлении, ни строки о беде не пишется, а
|
||
накопленное состояние не меняется
|
||
|
||
#### Scenario: Журнал невелик
|
||
|
||
- **GIVEN** в журнале меньше страниц, чем названный порог
|
||
- **WHEN** чекпойнт не смог перенести всё
|
||
- **THEN** строка `WARN` не пишется
|
||
|
||
#### Scenario: Состояние держится
|
||
|
||
- **GIVEN** о непродвижении журнала уже сказано
|
||
- **WHEN** следующий чекпойнт застаёт журнал того же размера
|
||
- **THEN** строка не повторяется
|
||
|
||
#### Scenario: Журнал разобрался
|
||
|
||
- **GIVEN** о непродвижении журнала было сказано
|
||
- **WHEN** очередной чекпойнт переносит журнал ЦЕЛИКОМ
|
||
- **THEN** о возврате к норме сказано один раз
|
||
|
||
#### Scenario: Журнал стал мал, но не перенесён
|
||
|
||
- **GIVEN** о непродвижении журнала было сказано
|
||
- **WHEN** очередной чекпойнт переносит не всё, а журнал при этом ниже порога
|
||
- **THEN** о возврате к норме не сообщается: перенос — это то, что проверено, а
|
||
размер ниже порога — нет
|
||
|
||
#### Scenario: Чекпойнт отказал
|
||
|
||
- **GIVEN** очередной чекпойнт вернул ошибку
|
||
- **WHEN** наступает следующий период
|
||
- **THEN** отказ виден строкой лога, а чекпойнт выполняется снова
|
||
|
||
### Requirement: Обслуживание журнала останавливается дренированием
|
||
|
||
Система SHALL останавливать периодический чекпойнт осознанно: горутина MUST
|
||
получать отмену и MUST быть дождана вместе с воркером свёртки — база
|
||
закрывается только после выхода **обеих**. Обрывать её выходом процесса
|
||
MUST NOT, а закрывать базу по выходу одной из двух MUST NOT: закрытая из-под
|
||
воркера, она даёт `ERROR` по доставке, с которой всё в порядке.
|
||
|
||
Идущий чекпойнт при отмене прерывается, и терять ему нечего: перенос страниц
|
||
идемпотентен, исхода разбора чекпойнт не пишет, а следующий старт берёт журнал с
|
||
того же места. Прерывание по отмене отказом MUST NOT считаться.
|
||
|
||
Исчерпание бюджета остановки MUST называть этап, не утверждая большего, чем
|
||
проверено: ждут двоих, и назвать виновным одного из них — догадка.
|
||
|
||
Отдельного чекпойнта на остановке система выполнять MUST NOT: закрытие
|
||
последнего соединения к базе SQLite делает его само. Условие названо в
|
||
требовании о версии витрины — щуп обязан быть закрыт раньше пула, иначе
|
||
последнего соединения не наступает вовсе.
|
||
|
||
**Остаток назван вслух: в ветке исчерпанного бюджета база не закрывается, а
|
||
значит финального чекпойнта не наступает и рядом с ней остаётся `-wal`.**
|
||
Данные при этом целы — следующее открытие проиграет журнал, — но файл базы в
|
||
этом состоянии переносить без его `-wal` нельзя. Процедура подмены при
|
||
пересборке этого и требует: она удаляет `-wal` старой базы вместе с ней самой.
|
||
|
||
#### Scenario: Сервис останавливается
|
||
|
||
- **WHEN** сервис получает сигнал остановки
|
||
- **THEN** горутина чекпойнта завершается до закрытия базы
|
||
|
||
#### Scenario: Чекпойнт идёт в момент остановки
|
||
|
||
- **GIVEN** чекпойнт выполняется, когда пришла отмена
|
||
- **WHEN** он прерывается
|
||
- **THEN** отказ не пишется, новый цикл не начинается, и горутина выходит
|
||
|
||
#### Scenario: Бюджет остановки исчерпан
|
||
|
||
- **GIVEN** фоновые горутины не вышли в бюджет
|
||
- **WHEN** сервис завершается
|
||
- **THEN** база не закрывается, а запись лога называет этап, не указывая
|
||
виновной горутины
|
||
|
||
### Requirement: Реестр наблюдённых категориальных значений
|
||
|
||
Хранилище SHALL держать реестр категориальных значений, которые приносил поток:
|
||
одна строка на тройку `(метрика или секция, поле, значение)`, с выведенным кодом
|
||
HealthKit рядом. Значение хранится **дословно**, тем же текстом, каким пришло.
|
||
|
||
Локаль в ключ MUST NOT входить. Она живёт в заголовке доставки, которого нет в
|
||
сыром архиве, — ключ с локалью сделал бы состояние функцией от того, уцелела ли
|
||
строка учёта: усыновлённая доставка положила бы вторую строку с пустой локалью.
|
||
Язык, на котором приехала строка, восстанавливается по доставке провенанса.
|
||
|
||
Реестр SHALL нести выведенный код и провенанс **первой** встречи — метку приёма
|
||
и идентификатор доставки, в которой строка появилась впервые по порядку журнала.
|
||
Первая встреча отвечает на вопрос «когда сменился язык телефона»; счётчик встреч
|
||
не заводится вовсе — он не идемпотентен при повторной свёртке той же доставки, а
|
||
значит сделал бы состояние зависящим от числа прогонов.
|
||
|
||
Пустой код — законное состояние строки: он означает «словарь этой строки не
|
||
знает», и перечень таких строк есть заявка на пополнение словаря.
|
||
|
||
Словарь, по которому выводится код, SHALL жить в бинаре, а не в таблице базы и
|
||
не в строках миграции. Таблица, наполняемая руками, стала бы входом, которого
|
||
нет в журнале, и `import + replay` перестал бы задавать состояние однозначно;
|
||
данные, засеянные миграцией, живут в двух местах сразу и расходятся с бинарём
|
||
молча после первой же правки словаря.
|
||
|
||
Строки реестра SHALL писаться **той же транзакцией**, что и точки доставки.
|
||
Доставка — единица свёртки; частичное состояние «точки легли, реестр нет»
|
||
повторная свёртка не чинит: хеш содержимого сойдётся, и объекты переписываться
|
||
не станут.
|
||
|
||
Обслуживания у реестра нет: строки не удаляются. Строка, единственные доставки
|
||
которой выпали из журнала подрезкой архива, переживёт их в рабочей витрине и не
|
||
появится в пересобранной. Это тот же класс, что «верхние слои за периоды с
|
||
удалёнными доставками», и он MUST называться, а не досчитываться.
|
||
|
||
#### Scenario: Фаза сна попадает в реестр с кодом
|
||
|
||
- **WHEN** свёрнута доставка с фазой сна «Во сне» и локалью `ru`
|
||
- **THEN** в реестре есть строка `sleep_analysis / value / «Во сне»` с кодом
|
||
`HKCategoryValueSleepAnalysisAsleepUnspecified`
|
||
- **AND** содержимое точки в часовом объекте не изменилось
|
||
|
||
#### Scenario: Та же строка без заголовка локали даёт ту же строку реестра
|
||
|
||
- **WHEN** та же строка приехала доставкой без `Accept-Language`
|
||
- **THEN** второй строки в реестре не появляется
|
||
|
||
#### Scenario: Строка без кода в реестре видна
|
||
|
||
- **WHEN** свёрнута доставка с `heart_rate.context`, которого словарь не знает
|
||
- **THEN** в реестре есть строка с этим значением и пустым кодом
|
||
|
||
#### Scenario: Отказ слияния не оставляет строк реестра
|
||
|
||
- **WHEN** слияние доставки отказало
|
||
- **THEN** реестр не содержит значений этой доставки
|
||
|
||
### Requirement: Реестр детерминирован по журналу
|
||
|
||
Повторная свёртка той же доставки MUST оставлять реестр без изменений, а
|
||
провенанс первой встречи MUST быть **минимумом** по порядку журнала
|
||
(`received_at`, затем идентификатор доставки), а не значением последней записи.
|
||
|
||
Проигрывание журнала в любом порядке доставок, дающем тот же префикс, SHALL
|
||
приводить реестр к тому же состоянию. Реестр — свёртка по журналу, как и всё
|
||
остальное в витрине.
|
||
|
||
Выведенный код SHALL обновляться при каждой встрече строки: словарь живёт в
|
||
бинаре, и пересборка обязана давать код по текущему словарю, а не по тому,
|
||
который действовал при первой свёртке. Строка, переставшая приезжать, держит код
|
||
прежнего словаря до пересборки — это осознанная цена материализации, и на
|
||
сходимость она не влияет, потому что код в отпечаток не входит.
|
||
|
||
#### Scenario: Повторная свёртка ничего не меняет
|
||
|
||
- **WHEN** одна и та же доставка свёрнута дважды
|
||
- **THEN** строки реестра и их провенанс совпадают с состоянием после первой
|
||
свёртки
|
||
|
||
#### Scenario: Провенанс — самая ранняя доставка
|
||
|
||
- **WHEN** одна строка приехала сперва поздней доставкой, затем ранней
|
||
- **THEN** провенансом остаётся ранняя по журналу
|
||
|
||
#### Scenario: Порядок свёртки на реестр не влияет
|
||
|
||
- **WHEN** три доставки свёрнуты во всех шести порядках
|
||
- **THEN** реестр и его раздел отпечатка совпадают у всех шести прогонов
|
||
|
||
#### Scenario: Пересборка даёт тот же реестр
|
||
|
||
- **WHEN** журнал проигран в свежую витрину
|
||
- **THEN** реестр пересобранной витрины совпадает с реестром исходной
|
||
|
||
### Requirement: Отпечаток покрывает наблюдение реестра, но не выведенный код
|
||
|
||
Отпечаток витрины SHALL покрывать реестр категориальных значений отдельным
|
||
разделом: ключ и провенанс первой встречи. Иначе недетерминированная запись
|
||
реестра прошла бы мимо единственного оракула сходимости.
|
||
|
||
Выведенный код в отпечаток входить MUST NOT. Ключ и провенанс — функция журнала;
|
||
код — функция журнала **и версии словаря в бинаре**. Включённый в отпечаток, он
|
||
заставил бы всякое пополнение словаря давать расхождение при побайтно совпавшем
|
||
журнале, а пополнение объявлено рабочим циклом. Человек, принимающий по
|
||
отпечатку необратимое решение о подмене базы, читал бы это как дефект.
|
||
Правильность вывода кода проверяется тестами словаря — это другой вопрос, и
|
||
смешение обесценило бы оракул.
|
||
|
||
Раздел SHALL быть отличим от прочих признаком впереди строки, а поля переменной
|
||
длины SHALL идти с длиной впереди: два разных состояния витрины не имеют права
|
||
дать один отпечаток.
|
||
|
||
Цена названа вслух: у витрины, свёрнутой до этого изменения, реестр пуст, и
|
||
первая же сверка отпечатков после выкатки покажет расхождение. Это законное
|
||
расхождение, а не дефект; отчёт пересборки обязан назвать его ожидаемым классом
|
||
и напечатать счётчик строк реестра.
|
||
|
||
#### Scenario: Разошедшееся наблюдение меняет отпечаток
|
||
|
||
- **WHEN** у двух витрин совпадают объекты и сущности, но в реестре одной есть
|
||
строка, которой нет в другой
|
||
- **THEN** отпечатки различны
|
||
|
||
#### Scenario: Расхождение только по коду отпечаток не двигает
|
||
|
||
- **WHEN** две витрины несут те же строки реестра с тем же провенансом, но
|
||
разными кодами
|
||
- **THEN** отпечатки совпадают
|
||
|
||
#### Scenario: Значение с разделителем границу поля не подделывает
|
||
|
||
- **WHEN** значение содержит признак раздела, цифры и нулевой байт
|
||
- **THEN** отпечаток отличается от отпечатка витрины с другим разбиением тех же
|
||
байтов по полям
|
||
|
||
#### Scenario: Пустой реестр отпечаток не ломает
|
||
|
||
- **WHEN** в витрине нет ни одной строки реестра
|
||
- **THEN** отпечаток считается и совпадает с отпечатком такой же витрины без
|
||
реестра
|
||
|
||
### Requirement: Значения категориальных строк не попадают в логи
|
||
|
||
Сами наблюдённые строки и выведенные коды MUST NOT попадать в записи лога выше
|
||
`DEBUG`; в журнал SHALL уходить только счётчики — сколько значений наблюдалось и
|
||
сколько осталось без кода. Основание: строки категориальных значений — данные о
|
||
здоровье, контекст пульса и фаза сна описывают человека не меньше, чем число.
|
||
|
||
Проверка отсутствия строки в логе SHALL разбирать запись и сравнивать значения
|
||
полей, а не искать подстроку в сыром буфере: служебная метка времени содержит
|
||
произвольные цифры, и поиск по буферу делает тест флаки по построению.
|
||
|
||
#### Scenario: Свёртка доставки со сном не пишет строк в лог
|
||
|
||
- **WHEN** свёрнута доставка с фазами сна и контекстом пульса
|
||
- **THEN** в разобранных записях лога нет ни одной наблюдённой строки и ни
|
||
одного кода
|
||
- **AND** счётчики значений без кода в записи присутствуют
|
||
### Requirement: Проигрыш пришедшей точки считается отдельно
|
||
|
||
Итог разбора доставки SHALL нести счётчик координат, где пришедшая точка
|
||
проиграла сохранённой, и координаты первых таких объектов — метрику, слой и
|
||
час, без значений точек.
|
||
|
||
Счётчик SHALL быть отдельным от счётчика перезаписей. Перезаписи считают
|
||
столкновение в обе стороны (на корпусе 2026-08-04 они сработали бы около 84 000
|
||
раз), и отличить по ним «оставили пришедшую» от «выбросили пришедшую» нельзя —
|
||
то есть единственное событие, ради наблюдения за которым правило и переписано,
|
||
остаётся невидимым.
|
||
|
||
Под новым правилом пришедшая точка проигрывает ровно тогда, когда сохранённая
|
||
**строго полнее**. То есть счётчик меряет одно направление правила полноты — то,
|
||
в котором оно спорит с журналом; случай «пришедшая строго полнее» им не
|
||
считается, потому что там полнота и журнал согласны. Молчащий счётчик означает,
|
||
что правило полноты перестало спорить вовсе, и это событие для разбора, а не
|
||
отказ.
|
||
|
||
Счётчик SHALL быть виден не только в логе свёртки, но и в отчёте пересборки —
|
||
рядом с удержанными версиями сущностей и по той же причине: сходимость
|
||
отпечатка правило удержания не проверяет по построению, живой приём и пересборка
|
||
пользуются одним правилом и одинаково сойдутся на одинаково удержанной точке.
|
||
|
||
Значений точек счётчик и его координаты содержать MUST NOT — данные о здоровье
|
||
чувствительнее токенов.
|
||
|
||
#### Scenario: Удержание сохранённой точки видно в итоге доставки
|
||
|
||
- **GIVEN** по координатам сохранена точка, строго более полная, чем пришедшая
|
||
- **WHEN** доставка сворачивается
|
||
- **THEN** счётчик удержаний растёт
|
||
- **AND** координаты объекта попадают в список удержаний
|
||
- **AND** счётчик остаётся нулевым, когда побеждает пришедшая точка
|
||
|
||
### Requirement: Потеря содержания пришедшей точкой считается и не молчит
|
||
|
||
Система SHALL считать отдельным счётчиком координаты, где победителем оказалась
|
||
**пришедшая** точка, а у проигравшей был ключ с непустым значением, которого у
|
||
победительницы нет. Координаты таких объектов SHALL попадать в лог, и система
|
||
SHALL писать `WARN` — без значений точек.
|
||
|
||
Это единственное направление, в котором новое правило способно потерять
|
||
содержание, и защитить его нечем **по построению**: разряд полноты в этом
|
||
случае погашен — значения общих содержательных ключей разошлись, и надмножество
|
||
имён о полноте не говорит ничего. До смены тай-брейка тот же исход случался по
|
||
жребию байтового порядка и так же молча; разница в том, что теперь он
|
||
детерминирован, а значит либо не случается вовсе, либо случается всегда.
|
||
|
||
Счётчик SHALL быть отдельным и от перезаписей, и от удержаний: перезаписи
|
||
считают столкновение в обе стороны, удержания — противоположное направление, и
|
||
ни один из них на вопрос «потеряли ли мы содержание» не отвечает.
|
||
|
||
Запрещать такое слияние система SHALL NOT: измерено — 2 координаты из 80 129
|
||
спорных на живом корпусе, и обе те же, что дают несравнимые наборы. Событие
|
||
обратимо пересборкой, пока жив архив, поэтому вместо запрета — наблюдение, тем
|
||
же решением и по той же причине, по какой отложено объединение полей.
|
||
|
||
Значений точек ни счётчик, ни координаты, ни запись содержать MUST NOT.
|
||
|
||
#### Scenario: Пришедшая точка унесла содержательный ключ сохранённой
|
||
|
||
- **GIVEN** сохранена точка `{date, asleep:7.5, rem:1.2}`
|
||
- **WHEN** следующей доставкой приезжает точка `{date, asleep:7.4}`
|
||
- **THEN** в витрине остаётся пришедшая точка
|
||
- **AND** счётчик потери содержания растёт, а счётчик удержаний — нет
|
||
- **AND** система пишет `WARN` с координатами объекта и без значений точек
|
||
|
||
#### Scenario: Пришедшая точка ничего не унесла
|
||
|
||
- **WHEN** множества ключей у сохранённой и пришедшей совпадают, а значения
|
||
различаются
|
||
- **THEN** счётчик потери содержания не растёт
|