Дозакрыты находки ревью по слиянию сущностей
- Правило покрытия получило второй разряд (условный, как у точек), запрет вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и ряд из null больше не затирают маршрут. Победитель внутри доставки стал функцией множества версий — общим помощником с точками, — а провенанс поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину к прежнему содержимому. - Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает на старте; текст ошибки разбора не несёт значений из тела. - Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты: безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя был квадратичен по числу присланных версий одного ключа.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-02
|
||||
@@ -0,0 +1,374 @@
|
||||
## Context
|
||||
|
||||
Семь находок дозапущенных проходов ревью (`tmp/triage-late.md`) по коммиту
|
||||
`f8200f7`. Каждая имеет прогнанный падающий оракул; оракулы переезжают обычными
|
||||
тестами пакетов, `tmp/` в `.gitignore` и жить в нём им нельзя.
|
||||
|
||||
Ограничения, которые задача не выбирает, а наследует:
|
||||
|
||||
- **Свёртка обязана быть функцией журнала.** Живая витрина и `reindex` обязаны
|
||||
сходиться отпечатком; всё, что зависит от порядка свёртки или от порядка
|
||||
элементов на проводе, — дефект по определению.
|
||||
- **Тренировка приезжает повторно, пока источник её досчитывает** (26 копий,
|
||||
три различных содержимых на живом архиве), и значения между копиями
|
||||
расходятся **всегда**. Поэтому правило полноты точек к сущностям неприменимо,
|
||||
и `Covers` существует отдельно от `Relate`.
|
||||
- **Маршрут — 95% веса тренировки**, и в экспорте Apple его нет вовсе.
|
||||
Затирание маршрута необратимо: `reindex` проиграет журнал и получит то же.
|
||||
- **Цена канонизации измерена**: тело 40 МиБ → пик кучи 768.3 МиБ; 63 МиБ →
|
||||
блокировка удерживается 5.019 с при `busy_timeout` 5000.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Обеднённая версия сущности не замещает сохранённую и не пропадает из счётчика.
|
||||
- Победитель — функция множества версий: и внутри доставки, и между доставками.
|
||||
- Провенанс сущности отражает победителя по журналу, а не первую свёрнутую копию.
|
||||
- Поле не той формы стоит одного поля, а не сущности; пропуск виден в базе.
|
||||
- Откат бинаря поверх новой схемы отказывает, а не стартует молча.
|
||||
- Каноническая форма сущности считается один раз и вне транзакции.
|
||||
- Диагностика разбора не несёт значений из тела.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Хранение сущности с `id` и неразобранной меткой (NULL-метка) — требует схемы
|
||||
и правил чтения витрины.
|
||||
- Пределы на размер одной сущности и суммарный размер секции, потоковый расчёт
|
||||
формы и хеша — новая политика, а не правка.
|
||||
- Объединение полей несравнимых версий — отвергнуто там же и по той же причине,
|
||||
что для точек.
|
||||
- Поэлементная сверка **содержимого** элементов ряда (точка маршрута без
|
||||
`altitude`) — см. «Риски».
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. `Covers` получает второй разряд, запрет вырождения формы и счёт содержательных элементов
|
||||
|
||||
Сегодня `Covers(g)` требует от `f` лишь наличия каждого содержательного ключа
|
||||
`g` и длины верхнеуровневого массива не меньше. Этого хватает, чтобы «скелет»
|
||||
(`{"maxHeartRate":1,"heartRateData":[null,null]}` против настоящей тренировки)
|
||||
признался равным настоящей и выиграл тай-брейк журнала.
|
||||
|
||||
Правило дополняется тремя проверками, все — по верхнему уровню:
|
||||
|
||||
1. **Второй разряд: ключи без содержания.** Каждый ключ `g` (включая пустые)
|
||||
обязан быть у `f`. Тот же стандарт записан для точек в `canon.Relate`:
|
||||
«иначе `{date, qty, Min:0, Max:0}` и `{date, qty}` неразличимы, и `Min` с
|
||||
`Max` исчезли бы из витрины по жребию тай-брейка». Отличие от `Relate`:
|
||||
там второй разряд применяется только при равенстве первого, здесь включение
|
||||
всех ключей требуется безусловно. Это эквивалентно при равном первом разряде
|
||||
и строже, когда первый разряд неравен, — а строже здесь и нужно: `Covers`
|
||||
отвечает «не потеряем ли содержания».
|
||||
2. **Запрет вырождения формы.** Покрывающая версия не может подменить объект
|
||||
или массив скаляром: если `g[k]` — объект, `f[k]` обязан быть объектом; если
|
||||
массив — массивом. Обратное (скаляр у `g`, объект у `f`) разрешено: `f`
|
||||
богаче формой. Стоимость — O(ключей), первый байт литерала.
|
||||
3. **Содержательные элементы массива.** Рядом с длиной считается число
|
||||
**непустых** элементов. `[null,null,null]` и `[{},{},{}]` сохраняют длину, но
|
||||
не несут ничего, а именно так выглядит затёртый маршрут.
|
||||
|
||||
Про третью проверку важно, что она **не добавляет обхода**: `arrayLen` уже
|
||||
сегодня декодирует каждый элемент массива в выбрасываемый `json.RawMessage`,
|
||||
чтобы посчитать их количество. Добавляется `isEmpty` по литералу элемента —
|
||||
проверка первого байта и, для чисел, `strconv`; в дерево значений элемент не
|
||||
разворачивается. Поэтому цена, названная триажем («полный обход маршрута на
|
||||
каждое сравнение»), уже уплачена, и решение её не умножает.
|
||||
|
||||
**Отвергнуто:** сверка содержимого *внутри* элемента ряда (набор ключей у
|
||||
каждой точки маршрута). Вот она обход действительно умножила бы — на 768 МиБ
|
||||
пика, измеренных пунктом 4, — и остаётся названным пределом (см. «Риски»).
|
||||
|
||||
**Отношение остаётся частичным порядком** — конъюнкция включений множеств и
|
||||
нестрогих неравенств по каждому общему ключу транзитивна. На это опирается
|
||||
решение 2.
|
||||
|
||||
### 2. Победитель внутри доставки — минимум канонической формы среди непревзойдённых
|
||||
|
||||
Попарная свёртка `pickWithinDelivery` нетранзитивна: полнота — частичный
|
||||
порядок, тай-брейк — тотальный, и вместе они образуют цикл. Стандарт для точек
|
||||
записан в `architecture.md` («победитель — функция множества точек, а не порядка
|
||||
их поступления») и реализован в `store.resolve`; для сущностей он применяется
|
||||
дословно: собрать версии ключа, отбросить строго покрытые, среди оставшихся
|
||||
взять минимум канонической формы.
|
||||
|
||||
Порядок обязан быть тотальным **до конца**. У точек кандидаты с равной
|
||||
канонической формой схлопываются ещё до сравнения, поэтому минимум единственен;
|
||||
у сущностей схлопывания нет, и при двух версиях, различающихся только порядком
|
||||
ключей или дребезгом последнего разряда (измеренная норма HAE), «минимум формы»
|
||||
неединственен — в хранилище лёг бы тот элемент, что стоял в массиве раньше.
|
||||
Поэтому последним разрядом сравнения идут исходные байты, ровно тем же
|
||||
движением и по той же причине, что записана в `canon.Less`.
|
||||
|
||||
«Строго покрыта» определено явно: покрыта другой версией и сама её не покрывает.
|
||||
Покрытие — предпорядок, две версии могут покрывать друг друга взаимно, и наивное
|
||||
«выбросить всё, что кем-то покрыто» опустошило бы множество, потеряв обе.
|
||||
|
||||
Счётчик «в одном теле приехали две версии одного ключа с разным содержанием»
|
||||
становится функцией множества тем же движением: считаются кандидаты, чья
|
||||
каноническая форма отличается от формы победителя.
|
||||
|
||||
Здесь требование постановки исполнено **по канонической форме, а не по байтам**,
|
||||
и это сказано прямо, потому что постановка говорит «при равном содержании и
|
||||
разных байтах обязан считать `differs=true`». Цель постановки — чтобы счётчик
|
||||
перестал молчать на двух настоящих разных версиях — достигается: случай оракула
|
||||
(маршрут против маршрута из `null`) считается. Побайтовый вариант отвергнут
|
||||
записанным инвариантом: порядок ключей в JSON от HAE нестабилен и дребезг
|
||||
последнего разряда тоже, ради чего канонизация и заведена, — счётчик по байтам
|
||||
срабатывал бы на норме потока и стал бы неотличим от шума ровно тогда, когда
|
||||
понадобился бы. Детерминизм при этом обеспечен не счётчиком, а тай-брейком по
|
||||
байтам выше.
|
||||
|
||||
`pickWithinDelivery` исчезает: она была парной формой того, что теперь делает
|
||||
множество.
|
||||
|
||||
Отбор «максимальные элементы плюс минимум по тотальному порядку» существует в
|
||||
проекте для точек (`store.resolve`) и объявлен стандартом в `architecture.md`.
|
||||
Второй рукописный экземпляр — ровно то, чем был `pickWithinDelivery`, и он
|
||||
разошёлся со стандартом нетранзитивностью. Поэтому механизм выносится в общего
|
||||
помощника, а точки и сущности становятся двумя его вызовами с разными
|
||||
отношениями: `architecture.md` уже обещает смену тай-брейка точек, когда род
|
||||
метрики будет измерен, то есть правка одного экземпляра при живом втором
|
||||
запланирована заранее.
|
||||
|
||||
### 3. Провенанс обновляется при совпавшем хеше
|
||||
|
||||
Совпал хеш — содержимое то же, писать нечего. Но провенанс
|
||||
(`delivery_id`/`delivery_received_at`) остаётся от первой свёрнутой копии, а не
|
||||
от победителя журнала. Следствия два: провенанс устаревает гарантированно на
|
||||
каждой из ~26 повторных присылок, и при возврате содержимого к прежнему
|
||||
(A→B→A) живая витрина расходится с `reindex` — тай-брейк пункта 4 правила
|
||||
сравнивает позиции, а сохранённая позиция неверна.
|
||||
|
||||
Решение: при совпавшем хеше сравнить позиции журнала и, если сохранённая
|
||||
раньше приехавшей, обновить **только** колонки провенанса. Провенанс становится
|
||||
максимумом по журналу среди версий с этим содержимым, то есть функцией
|
||||
множества.
|
||||
|
||||
`updated_at` при этом не двигается — и это отдельное решение, а не экономия.
|
||||
Тренировка приезжает до двадцати шести раз, и бамп метки на каждой сделал бы её
|
||||
меткой **касания строки**, а не изменения содержимого. Потребитель запроса «что
|
||||
изменилось с момента X» — естественного для коллектора и уже заказанного Read
|
||||
API — получил бы двадцать шесть ложных изменений, неотличимых от настоящего
|
||||
досчёта, и выяснилось бы это после того, как потребитель написан. Провенанс
|
||||
несёт собственную метку (время приёма своей доставки), и для тай-брейка её
|
||||
достаточно.
|
||||
|
||||
Счётчик записанных сущностей такое обновление **не** увеличивает: он считает
|
||||
содержимое витрины, и его сравнимость с прежними замерами важнее, чем учёт
|
||||
обновления. Отпечаток витрины провенанса не включает, поэтому сходимость
|
||||
`reindex` от этого решения не зависит — она зависит от него косвенно, через
|
||||
тай-брейк.
|
||||
|
||||
Слово «провенанс» после этого означает у сущности не то, что у часового
|
||||
объекта: у объекта хранится доставка, **создавшая** его, и она не поднимается.
|
||||
Асимметрия законная — у объекта нет замещения версии целиком, — но записана в
|
||||
спеке явно, иначе читатель перенесёт смысл с одного на другое.
|
||||
|
||||
### 4. Каноническая форма считается один раз, вне транзакции
|
||||
|
||||
Комментарий `bucket.go` утверждает, что канонизация вынесена наружу; фактически
|
||||
`analyze()` зовётся из `compareEntities` **внутри** `inTx`, а его результат
|
||||
пишется в **копию** элемента среза и не переживает даже одной попытки.
|
||||
|
||||
Ключевое наблюдение: `canon.Hash(raw)` уже считает полную каноническую форму и
|
||||
**выбрасывает** её, а `analyze()` считает ту же форму заново. То есть дорогая
|
||||
часть и так платится на каждой версии, в `prepareEntities`, вне транзакции.
|
||||
|
||||
Решение: `canon` отдаёт форму и хеш **одним проходом** (`FormAndHash`, внутри —
|
||||
запись в `io.MultiWriter(буфер, sha256)`, той же формой, какой уже написан
|
||||
`HashAll`), `newEntityVersion` зовёт его один раз и держит результат вместе с
|
||||
`canon.Analyze`. Ленивость, `analyze()` и флаг `parsed` уходят.
|
||||
|
||||
Отдельный вызов `HashForm(form []byte)` отвергнут: он вводит контракт
|
||||
очерёдности («сперва `Form`, потом `HashForm`»), где передача сырых байт вместо
|
||||
формы даёт правдоподобный, но неверный хеш, — а компилятор такую подмену не
|
||||
ловит.
|
||||
|
||||
Баланс работы назван честно, потому что он не односторонний:
|
||||
|
||||
- на пути **разошедшегося хеша** (одна доставка из сорока четырёх) — минус одна
|
||||
полная канонизация: сегодня форма считается дважды;
|
||||
- на пути **совпавшего хеша** (сорок три из сорока четырёх) — плюс один мелкий
|
||||
разбор `json.Unmarshal` в `map[string]json.RawMessage`, то есть проход по телу
|
||||
и копия каждого верхнеуровневого значения. Сегодня на этом пути `analyze()` не
|
||||
зовётся вовсе.
|
||||
|
||||
Плюс оценивается величиной входа, минус — величиной входа с константой развёртки
|
||||
в дерево значений, так что суммарно решение не дороже. Но «строго меньше» было
|
||||
бы неправдой, и приёмка меряет пик кучи до и после, а не верит рассуждению.
|
||||
|
||||
Разбор **сохранённой** версии остаётся внутри транзакции: её содержимое читается
|
||||
оттуда же и только когда хеш разошёлся (одна доставка из сорока четырёх).
|
||||
Убрать это можно лишь оптимистичным чтением до транзакции с перепроверкой
|
||||
внутри — это уже пределы размера и потоковый расчёт, то есть остаток.
|
||||
|
||||
### 5. Страж версии схемы переезжает в `Open`
|
||||
|
||||
`OpenForRead` сверяет версию схемы и отказывает при расхождении; `Open`
|
||||
мигрирует безусловно. Поэтому старый бинарь поверх схемы 7 стартует молча,
|
||||
незнакомые секции игнорирует, а доставки за окно отката помечает разобранными —
|
||||
и ничто не намекает, что для этого окна нужен `reindex`.
|
||||
|
||||
Асимметрия у `Open` законная: версия базы **ниже** версии бинаря — это ровно то,
|
||||
ради чего миграции существуют. Отказ ставится на «версия базы **выше** версии
|
||||
бинаря». Асимметрия относится только к `Open`: `OpenForRead` сохраняет строгое
|
||||
равенство, как требует спека пересборки, — иначе `reindex` начал бы читать
|
||||
рабочую базу схемы старее бинаря по колонкам, которых там нет. В одно место
|
||||
выносится **чтение** версии, а не сравнение.
|
||||
|
||||
Чтение берётся у самого goose (`Provider.GetVersions` отдаёт и текущую версию
|
||||
базы, и целевую), потому что имя таблицы учёта, имя колонки и правило «максимум
|
||||
= текущая версия» принадлежат ему: рукописная копия его приватной схемы
|
||||
разошлась бы при обновлении зависимости — и не отказом, а тем, что страж
|
||||
перестал бы ловить. Если окажется, что на соединении только для чтения этот путь
|
||||
требует записи или отдаёт лишнюю задержку (SQLite-диалект goose не умеет
|
||||
`TableExists`, поэтому на отсутствующей таблице уходит в повторы), копия
|
||||
допустима, но одной функцией и с названной причиной — и тогда «таблицы нет»
|
||||
распознаётся структурно (`sqlite_master`) и `NULL` читается как `NULL`
|
||||
(`sql.NullInt64`), а не по тексту ошибки драйвера: сообщения драйвера не
|
||||
контракт, это уже записанное правило проекта.
|
||||
|
||||
**Цена отказа названа, потому что она реальна.** Страж останавливает сервис
|
||||
целиком, а телефон шлёт непрерывно и молча: доставка, не попавшая в архив, в
|
||||
журнал не попадает вовсе. Взвешено так: откат бинаря — действие оператора,
|
||||
который в этот момент рядом и видит crash-loop сразу; дыры плотных метрик за
|
||||
время простоя закроют широкий и глубокий проходы синхронизации. Не закроют
|
||||
`stateOfMind` — у него доставки HAE единственный источник, — и это цена решения.
|
||||
Она меньше цены молчания: молчаливый старт портит витрину за всё окно отката, а
|
||||
узнать об этом неоткуда, и после ретеншена тел чинить будет нечем. Отвергнуты:
|
||||
деградированный режим «принимать и архивировать, свёртку не начинать» (сохраняет
|
||||
оба инварианта, но заводит режим, о существовании которого надо помнить, и
|
||||
правила его видимости) и отказ только воркеру свёртки (требует доказать, что
|
||||
старый бинарь корректно пишет учёт в новую схему, — доказательства нет).
|
||||
|
||||
### 6. Мягкий заголовок сущности
|
||||
|
||||
`entityHead` держит `ID`/`Name`/`Date`/`Start`/`End` типизированными строками, и
|
||||
сущность теряется целиком при смене типа любого из пяти. Причина названа точно,
|
||||
потому что от неё зависит выбор решения: роняет не `encoding/json`, а строка
|
||||
`entity.go:51-53`, где любая ошибка разбора считается фатальной. Сам
|
||||
`json.Unmarshal` «skips that field and completes the unmarshaling as best it
|
||||
can» и возвращает `*UnmarshalTypeError`.
|
||||
|
||||
Отсюда напрашивается трёхстрочная альтернатива — `errors.As(err, &ute)` и
|
||||
продолжить с уже заполненным заголовком. Она **отвергается**, и по названной
|
||||
причине: та же документация тут же оговаривает — «it's not guaranteed that all
|
||||
the remaining fields following the problematic one will be unmarshaled». Разбор,
|
||||
построенный на дозаполнении, перестал бы быть функцией тела: одна и та же
|
||||
тренировка давала бы разный заголовок в зависимости от порядка ключей на
|
||||
проводе, а он у HAE нестабилен.
|
||||
|
||||
Решение — штатная точка расширения `encoding/json`: тип `softString` с
|
||||
`UnmarshalJSON`, который на нестроковом значении ничего не пишет и возвращает
|
||||
`nil`. Теги остаются декларативными, ручных извлечений нет, гарантия полная.
|
||||
Заодно сохраняется различение счётчиков: элемент, который сам не объект, даёт
|
||||
ошибку **верхнего** уровня и по-прежнему уходит в «не разобралось как объект», а
|
||||
не в «нет `id`».
|
||||
|
||||
`id` при этом остаётся требованием, а не полем: без него сущность не адресуема.
|
||||
Число вместо строки в `id` — это сменившаяся форма идентификатора, и
|
||||
превращать `42` в `"42"` значило бы придумать идентичность за источник. Такая
|
||||
сущность пропускается прежним счётчиком.
|
||||
|
||||
`start`, приехавший не строкой, на `date` **не** откатывается. Мягкое чтение
|
||||
объявляет непонятое значение отсутствующим, а фолбэк `start → date` существует
|
||||
для сущностей, у которых `start` не прислан вовсе; композиция этих двух правил
|
||||
подставила бы метку другого момента времени, неотличимую от настоящей и ничем не
|
||||
считаемую. Поэтому нестроковый `start` — это неразбираемая метка.
|
||||
|
||||
Граница правила названа вслух: оно закрывает смену **типа**, но не смену
|
||||
**формата строки**, а наблюдался именно дрейф формата дат. Тренировка с датой в
|
||||
незнакомом формате по-прежнему теряется целиком — теперь со счётчиком в базе, —
|
||||
и закрыть это может только хранение сущности с неразобранной меткой, вынесенное
|
||||
остатком.
|
||||
|
||||
**Пропуски становятся видны в базе.** Миграция `00008` добавляет доставке
|
||||
колонку `skipped_entities`; свёртка пишет туда сумму трёх счётчиков пропуска
|
||||
сущностей. Причина не в отчётности: ретеншен решает «что потеряется, если тело
|
||||
удалить», по базе, и сегодня получает ответ «терять нечего» ровно там, где
|
||||
теряется тренировка с маршрутом.
|
||||
|
||||
Статус доставки от пропуска сущности **не** меняется: `partial` определён
|
||||
списком непокрытых секций, и второй источник истины для него завёл бы ровно то
|
||||
расхождение, которое спека запрещает явно.
|
||||
|
||||
### 7. Диагностика разбора без значений из тела
|
||||
|
||||
`fmt.Errorf("… встречено %v", tok)` подставляет токен целиком: тело 8 МиБ даёт
|
||||
текст ошибки 8 МиБ, который уходит атрибутом `error` на уровень `WARN`.
|
||||
Инвариант «тела запросов только на `DEBUG` и с обрезкой» нарушен буквально.
|
||||
|
||||
Ошибка называет **тип токена** и `dec.InputOffset()`. Смещение полезнее
|
||||
значения: по нему место в теле находится в архиве, а значение из тела в логе не
|
||||
имеет права быть в принципе.
|
||||
|
||||
## Три формы решения главного узла и компромисс каждой
|
||||
|
||||
Главный узел — глубина сравнения содержания при слиянии версий сущности.
|
||||
Рассматривались три, и выбор записан не по умолчанию:
|
||||
|
||||
1. **Поэлементная сверка содержимого рядов** (у каждого элемента маршрута
|
||||
сравнивать набор ключей). Ловит всё, включая точку маршрута без `altitude`.
|
||||
Компромисс: полный обход маршрута с материализацией каждого элемента на
|
||||
каждое сравнение — умножение уже измеренных 768 МиБ пика; плюс пересмотр
|
||||
правила слияния целиком. Отвергнута ценой.
|
||||
2. **Второй разряд + запрет вырождения формы + счёт содержательных элементов**
|
||||
(выбрана). Ловит скелет из скаляров, обнулённый ряд и исчезающий пустой ключ.
|
||||
Компромисс: строже правила точек, поэтому чаще удерживает; событие видно
|
||||
счётчиком, но контроль требует вывести счётчик в отчёт пересборки — иначе
|
||||
мера «сходимость `verify:archive`» его не увидит по построению. Стоимость —
|
||||
O(ключей) плюс `isEmpty` на элементах в уже существующем обходе.
|
||||
3. **Принять предел, оставить только наблюдаемость** (счётчик по различию байт,
|
||||
предел записан в `architecture.md`). Компромисс: маршрут продолжает теряться
|
||||
необратимо при обеднённой версии, а восстановить его после ретеншена тел
|
||||
неоткуда — в экспорте Apple маршрута нет. Отвергнута последствием.
|
||||
|
||||
Форма (2) принята владельцем в постановке; здесь она не переоткрывается, а
|
||||
уточняется недостающими определениями (пустота элемента, строгость покрытия,
|
||||
тотальность порядка) и получает контроль, которого у неё не было.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Порча внутри элемента ряда не ловится** (точка маршрута без `altitude`:
|
||||
длина та же, элемент непуст, форма не выродилась) → предел записывается в
|
||||
`architecture.md` рядом с описанием `Covers` так же прямо, как он записан в
|
||||
комментарии кода. Закрыть его может только сверка с телом в архиве, а тело
|
||||
живёт до ретеншена.
|
||||
- **Второй разряд `Covers` строже прежнего правила** и может удержать версию,
|
||||
которая раньше замещала: досчёт, потерявший ключ с пустым значением, теперь
|
||||
проигрывает. Мера контроля — **не** сходимость `verify:archive`: живой приём и
|
||||
пересборка пользуются одним правилом и одинаково сойдутся на одинаково
|
||||
замороженной версии, то есть слишком строгое правило выглядело бы идеальной
|
||||
сходимостью. Контроль — счётчик удержаний, выведенный в отчёт пересборки, и
|
||||
замер его значения на живом архиве.
|
||||
- **`isEmpty` считает ноль пустотой**, и это переносится на элементы ряда: ряд
|
||||
настоящих нулей будет выглядеть опустошённым → ошибка направлена в безопасную
|
||||
сторону (удерживаем, а не затираем) и видна счётчиком; наблюдённые ряды HAE
|
||||
состоят из объектов. Записано в спеку, потому что после мерджа это часть
|
||||
правила слияния навсегда.
|
||||
- **Мягкий заголовок принимает больше входов**, то есть сущности, ранее
|
||||
уходившие в `SkippedEntityMalformed`, начнут попадать в витрину → это
|
||||
изменение разбора, и по правилу «покрыли — пересверните» такие доставки надо
|
||||
пересворачивать. Замер на живом архиве сделан **до** утверждения формулировок:
|
||||
118 тел, пропусков `noID=0 noTime=0 malformed=0`, то есть пересворачивать
|
||||
нечего, и data-миграции нет.
|
||||
- **Мягкий заголовок увеличивает долю тел, доходящих до канонизации**: сущность,
|
||||
раньше отсекавшаяся на разборе заголовка почти бесплатно, теперь канонизуется
|
||||
целиком, и худший случай по памяти становится достижим на входах, которые до
|
||||
него не доходили → предел на размер сущности из задачи-остатка перестаёт быть
|
||||
желательным и становится **обязательным условием**; записано в её теле.
|
||||
- **Обновление провенанса при совпавшем хеше — дополнительная запись** там, где
|
||||
раньше её не было: ~26 повторных присылок на тренировку → запись касается трёх
|
||||
колонок без `payload`, то есть не трогает самое тяжёлое; счётчик записанных
|
||||
сущностей и `updated_at` не двигаются, и сравнимость замеров сохраняется.
|
||||
- **Страж версии схемы отказывает при старте** — сервис не поднимется на базе
|
||||
из будущего, то есть приём останавливается, а телефон не перешлёт → цена
|
||||
взвешена выше, в решении 5, вместе с отвергнутыми альтернативами; в
|
||||
`architecture.md` уезжает эксплуатационный контракт: как это выглядит
|
||||
(crash-loop контейнера) и чем лечится (возврат бинаря).
|
||||
- **Пункт 5 правила (несравнимые наборы) остаётся функцией порядка
|
||||
проигрывания**, и второй разряд `Covers` делает этот исход чаще → приёмочный
|
||||
критерий сходимости сформулирован условно (перестановка даёт один отпечаток
|
||||
при нулевом счётчике несравнимых), а сам предел записан в `architecture.md`
|
||||
как единственная точка, где витрина не является функцией множества доставок.
|
||||
@@ -0,0 +1,107 @@
|
||||
## Why
|
||||
|
||||
Изменение «тренировки и записи с собственным `id`» (`f8200f7`) прошло ревью не
|
||||
полностью: проходы `adversary`, `ops` и архитектурный на коде не запускались.
|
||||
Дозапуск нашёл девять причин, триаж оставил семь — у каждой прогнанный оракул.
|
||||
Две из них необратимы по последствиям: обеднённая версия тренировки затирает
|
||||
маршрут молча (в экспорте Apple маршрута нет, `reindex` проиграет то же
|
||||
поражение), а одно поле не той формы уносит тренировку целиком, причём доставка
|
||||
при этом числится разобранной — то есть ретеншен, решающий «что потеряется,
|
||||
если тело удалить», получит ложное «терять нечего».
|
||||
|
||||
Остальные пять — молчание там, где обещана детерминированность: откат бинаря
|
||||
поверх новой схемы стартует без слова, победитель внутри доставки зависит от
|
||||
порядка элементов на проводе, провенанс устаревает на каждой из ~26 повторных
|
||||
присылок, канонизация идёт внутри транзакции вопреки собственному комментарию
|
||||
(измерено: 768 МиБ пика, 5.019 с удержания блокировки), а тело доставки в 8 МиБ
|
||||
целиком уезжает в текст ошибки и оттуда в `WARN`.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Сравнение содержания сущностей получает второй разряд и запрет вырождения
|
||||
формы.** Сегодня `Covers` смотрит только наличие ключа и длину
|
||||
верхнеуровневого массива, поэтому версия-скелет (каждый массив заменён
|
||||
массивом той же длины из `null`, каждый вложенный объект — скаляром)
|
||||
признаётся равной настоящей и выигрывает тай-брейк журнала. Добавляются: ключи
|
||||
без содержания вторым разрядом (как у точек — «иначе ключ с пустым значением
|
||||
исчезает по жребию»), запрет покрывающей версии подменять объект или массив
|
||||
скаляром, и счёт **содержательных элементов** верхнеуровневого массива рядом с
|
||||
его длиной. Предел правила остаётся названным вслух и не закрывается:
|
||||
сокращение **внутри** элемента ряда (точка маршрута без `altitude`) ловится
|
||||
только сверкой с телом в архиве.
|
||||
- **Победитель внутри одной доставки становится функцией множества версий, а не
|
||||
порядка элементов массива.** Стандарт уже записан для точек и для сущностей
|
||||
молча не применён: попарная свёртка частичного порядка с тотальным тай-брейком
|
||||
нетранзитивна — `[A,B,C]` даёт `C`, `[B,C,A]` даёт `A`. Механизм выносится в
|
||||
одного помощника, общего с точками. Счётчик различающихся версий при этом
|
||||
считает по **канонической форме**, а не по байтам: требование постановки
|
||||
(«differs при разных байтах») исполнено по форме, потому что порядок ключей у
|
||||
HAE нестабилен и побайтовый счётчик срабатывал бы на норме потока;
|
||||
детерминизм обеспечен тай-брейком по байтам, а не счётчиком.
|
||||
- **Провенанс обновляется при совпавшем хеше.** Сегодня совпадение хеша
|
||||
пропускает запись вместе с провенансом, и в сущности остаётся первая
|
||||
свёрнутая копия, а не победитель по журналу; при возврате содержимого к
|
||||
прежнему живая витрина расходится с `reindex`.
|
||||
- **Одно поле не того ТИПА больше не уносит сущность.** Пять полей заголовка
|
||||
читаются мягко — тем же принципом, который уже записан в коде для `duration`.
|
||||
Граница названа вслух: правило закрывает смену типа значения, но **не** смену
|
||||
формата строки, а наблюдался именно дрейф формата дат — тренировка с
|
||||
незнакомой датой по-прежнему теряется целиком, и закрыть это может только
|
||||
хранение сущности с неразобранной меткой (остаток). Пропуски сущностей при
|
||||
этом становятся видны в **учётной записи доставки**, а не только в логе,
|
||||
причём «не измерялось» отличимо от нуля.
|
||||
- **Счётчик удержанных версий выводится в отчёт пересборки.** Новое правило
|
||||
строже прежнего, а сходимость отпечатка его не проверяет по построению: живой
|
||||
приём и пересборка пользуются одним правилом и одинаково сойдутся на
|
||||
одинаково удержанной версии.
|
||||
- **`store.Open` сверяет версию схемы перед миграцией.** Версия базы выше
|
||||
версии бинаря — отказ, а не повод мигрировать; прецедент записан в
|
||||
`OpenForRead`.
|
||||
- **Канонизация сущности уезжает за транзакцию по-настоящему.** Каноническая
|
||||
форма считается один раз на версию, до входа в транзакцию, и из неё же
|
||||
берётся хеш — сегодня форма считается дважды (в хеше и в отложенном разборе),
|
||||
причём второй раз внутри `inTx`, который повторяется до пяти раз.
|
||||
- **Текст ошибки разбора перестаёт нести значения из тела**: называется тип
|
||||
токена и смещение во входе.
|
||||
- Лог `delivery failed` на приёме отличает занятость базы от прочих причин.
|
||||
- **Не входит:** хранение сущности с `id`, но неразобранной меткой (NULL-метка);
|
||||
пределы на размер одной сущности и секции и потоковый расчёт формы и хеша;
|
||||
принцип «data-миграции не отбирают строки по обрезаемым спискам»; длина
|
||||
очереди `pending` в `/stats`. Всё четыре уходят задачами беклога.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Новых нет. Все семь пунктов — уточнения уже записанных правил разбора и
|
||||
хранения; новое понятие ввёл бы второй словарь для того же домена.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `parsing`: заголовок сущности читается мягко — поле не той формы пропускает
|
||||
само поле, а не сущность; диагностика разбора не несёт значений из тела.
|
||||
- `storage`: содержание сущностей сравнивается с двумя разрядами, запретом
|
||||
вырождения формы и счётом содержательных элементов массива; победитель внутри
|
||||
доставки объявлен функцией множества с тотальным порядком; провенанс
|
||||
обновляется при совпавшем хеше, а метка изменения — нет; учётная запись
|
||||
доставки несёт число пропущенных сущностей, отличая ноль от «не измерялось»;
|
||||
открытие базы сверяет версию схемы; каноническая форма сущности считается вне
|
||||
транзакции и один раз.
|
||||
- `ingest`: лог отказа приёма отличает занятость базы от прочих причин.
|
||||
- `reindex`: число пропущенных сущностей внесено в перечень производных полей,
|
||||
которые пересборка не переносит; отчёт пересборки называет число удержанных
|
||||
версий сущностей.
|
||||
|
||||
## Impact
|
||||
|
||||
- `internal/canon` — `Covers`, форма массива, экспорт хеша по готовой форме.
|
||||
- `internal/store` — `entity.go` (версия сущности, слияние, провенанс),
|
||||
`bucket.go` (комментарий о канонизации), `store.go` (страж версии схемы),
|
||||
новая миграция `00008` (колонка `skipped_entities` у доставки).
|
||||
- `internal/hae` — `entity.go` (мягкий заголовок), `hae.go` (текст ошибок).
|
||||
- `internal/fold`, `internal/ingest` — проброс счётчика пропусков в учёт,
|
||||
различение занятости базы в логе.
|
||||
- `docs/architecture.md`, `docs/database.md`, `docs/conventions.md`,
|
||||
`docs/review-journal.md`.
|
||||
- Оракулы из `tmp/adv/` переезжают обычными тестами в `internal/canon`,
|
||||
`internal/store`, `internal/hae`.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Отказ учёта доставки называет класс причины
|
||||
|
||||
Система SHALL логировать отказ, случившийся **после** того, как тело легло в архив, но до появления учётной записи, так, чтобы владелец отличал **занятость базы** от прочих причин. Различается именно занятость: у неё уже есть доменная ошибка, и она означает конкуренцию за запись, которая будет повторяться.
|
||||
|
||||
Расширять признак до «обстоятельств вообще» система MUST NOT, хотя предикат с таким смыслом в проекте есть: он включает ещё и отмену работы снаружи, а на этом пути отмена невозможна по построению — учёт ведётся на контексте, переживающем обрыв соединения. Назвать отменённую работу занятостью базы значило бы отправить владельца искать конкуренцию там, где её нет.
|
||||
|
||||
Уровень при этом остаётся `ERROR` независимо от класса: тело лежит в архиве без
|
||||
учётной записи, то есть осиротело, и вернуть его в журнал может только
|
||||
пересборка. Занятость базы этого не отменяет — она объясняет причину, а не
|
||||
снимает работу. Смысл различения в другом: занятость означает конкуренцию за
|
||||
запись, которая будет повторяться и лечится не тем же, чем лечится сбой диска
|
||||
или испорченная база.
|
||||
|
||||
#### Scenario: Занятая база при учёте доставки видна как отдельный класс
|
||||
|
||||
- **WHEN** запись учёта доставки не проходит из-за занятости базы
|
||||
- **THEN** отказ логируется на уровне `ERROR` вместе с путём тела в архиве
|
||||
- **AND** запись отличает занятость базы от прочих причин отказа
|
||||
- **AND** тот же отказ по другой причине этого признака не несёт
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Разбор секций с собственными идентификаторами
|
||||
|
||||
Система SHALL разбирать секции тела, элементы которых несут собственный `id`, в
|
||||
**сущности**, а не в точки: у сущности нет ни слоя, ни координатного ключа
|
||||
`метрика + слой + начало + конец` — её адресует сам `id`.
|
||||
|
||||
Покрываются две такие секции: `data.workouts` и `data.stateOfMind`. Секции
|
||||
`ecg`, `symptoms`, `cycleTracking`, `medications` и `heartRateNotifications`
|
||||
покрытыми MUST NOT становиться: живой поток не приносил их ни разу (118
|
||||
доставок), их форма никем не наблюдалась, а полнота покрытия HealthKit ради
|
||||
полноты целью проекта не является. Они остаются в списке непокрытых, и доставка
|
||||
с ними остаётся `partial`.
|
||||
|
||||
Из тренировки разбор SHALL брать только то, по чему потом идёт выборка:
|
||||
идентификатор, имя, начало, конец, офсет исходной зоны и длительность. Всё
|
||||
остальное — включая маршрут, внутренние ряды (`heartRateData`,
|
||||
`activeEnergy`, `heartRateRecovery`) и сводки — MUST храниться содержимым
|
||||
сущности **дословно**, теми же байтами, какими пришло. Раскладывать структуру
|
||||
тренировки по колонкам значило бы решить за Apple, что в ней главное: сводки
|
||||
дублируют ряды (`distance` — это сумма `walkingAndRunningDistance`), а набор
|
||||
полей зависит от типа тренировки (у уличной есть `route`, `avgSpeed`,
|
||||
`flightsClimbed`, у домашней — `temperature`, `humidity`, `intensity`).
|
||||
|
||||
**Значение заголовка не того ТИПА SHALL стоить одного поля, а не сущности.**
|
||||
Каждое поле заголовка читается мягко: строка берётся, когда значение является
|
||||
строкой, и считается отсутствующей во всех прочих случаях. Правило уже записано
|
||||
для длительности («нечисловое значение — это пропуск ОДНОГО поля, а не сломанная
|
||||
сущность») и распространяется на весь заголовок. Иначе `name`, приехавшее
|
||||
числом, уносит тренировку вместе с маршрутом, а доставка при этом числится
|
||||
разобранной.
|
||||
|
||||
Мягкость MUST достигаться конструкцией, которая **не полагается на дозаполнение
|
||||
остальных полей** библиотекой разбора: `encoding/json` при несовпадении типа
|
||||
«skips that field and completes the unmarshaling as best it can», но тут же
|
||||
оговаривает, что дозаполнение полей **после** проблемного не гарантировано.
|
||||
Разбор, построенный на распознавании ошибки типа постфактум, перестал бы быть
|
||||
функцией тела: одна и та же тренировка давала бы разный заголовок.
|
||||
|
||||
Граница правила называется вслух: оно закрывает смену **типа** значения, но не
|
||||
смену **формата строки**. Наблюдавшийся дрейф — формата дат (разбор дат уже
|
||||
зависит от секции пакета), и метка в незнакомом формате по-прежнему уносит
|
||||
сущность целиком; закрыть это может только хранение сущности с неразобранной
|
||||
меткой, а это отдельная задача. Пропуск при этом перестаёт быть невидимым: он
|
||||
доходит до учётной записи доставки.
|
||||
|
||||
Идентификатор исключением из мягкости MUST быть: сущность без строкового `id`
|
||||
не адресуема, и приведение чужого нестрокового значения к строке было бы
|
||||
выдумыванием идентичности за источник. Такая сущность пропускается тем же
|
||||
счётчиком, что и сущность без `id`.
|
||||
|
||||
Началом сущности при **присутствующем, но непрочитанном** `start` подставляться
|
||||
`date` MUST NOT — включая `start: null`.
|
||||
«Значение не той формы» и «значения нет» здесь различаются: фолбэк на `date`
|
||||
существует для сущностей, у которых `start` не прислан вовсе, а подстановка
|
||||
другого поля вместо непонятого даёт метку **другого момента времени**, ничем не
|
||||
отличимую от настоящей. Такой `start` SHALL считаться неразбираемой меткой —
|
||||
тем же исходом и тем же счётчиком, что метка незнакомого формата. Различать
|
||||
надо именно «ключ был», а не «значение не той формы»: `null` тоже не даёт
|
||||
строки, и без этого различения он молча уводил бы тренировку на другой момент
|
||||
времени.
|
||||
|
||||
Мягкое чтение SHALL задавать поле целиком на каждое вхождение ключа, а не
|
||||
накапливать признаки между вызовами. JSON допускает повтор ключа с семантикой
|
||||
«побеждает последнее», и разбор ей уже следует; накопленный признак сделал бы
|
||||
заголовок функцией истории вызовов, а не тела.
|
||||
|
||||
Элемент секции, не являющийся объектом JSON, SHALL уходить в счётчик «не
|
||||
разобралось как объект» — включая `null`. Разбор в структуру на `null` ошибки не
|
||||
даёт, поэтому такой элемент без явной проверки попадал бы в счётчик «нет `id`»,
|
||||
и сменившаяся форма СЕКЦИИ диагностировалась бы как сменившаяся форма
|
||||
ИДЕНТИФИКАТОРА — ради различения которых два счётчика и заведены.
|
||||
|
||||
Длительность SHALL браться из тела, а не вычисляться из начала и конца: HAE
|
||||
шлёт `91.746` секунды при интервале в 91 секунду, и вычисленное значение молча
|
||||
разошлось бы с присланным. Отсутствие или нечисловое значение длительности
|
||||
сущность MUST NOT отбрасывать; такая длительность SHALL быть выражена
|
||||
отсутствием значения, а не нулём — ноль является законной длительностью, и
|
||||
потребитель не отличил бы «источник не прислал» от «измерено ноль».
|
||||
|
||||
Началом сущности SHALL быть `start`, при его отсутствии — `date`. Конец берётся
|
||||
из `end`; при отсутствии или неразбираемости конца он SHALL равняться началу, а
|
||||
истина остаётся в содержимом. Вырождение интервала здесь безопасно, в отличие от
|
||||
точки: ключ сущности — `id`, схлопывать координаты нечем. Офсет исходной зоны
|
||||
SHALL браться из начала: колонка одна, а тренировка через смену зоны дала бы
|
||||
два разных.
|
||||
|
||||
Из записи разбор SHALL брать идентификатор, род секции, метку времени и офсет;
|
||||
всё остальное хранится дословно. Род записи SHALL быть верхнеуровневым ключом
|
||||
секции HAE **дословно** (`stateOfMind`, не `state_of_mind`): инвариант «форма
|
||||
Apple не транслируется» относится и к именам секций.
|
||||
|
||||
Длина идентификатора SHALL быть ограничена, и сущность с более длинным `id`
|
||||
SHALL пропускаться тем же счётчиком, что и сущность без `id`. Идентификатор
|
||||
приходит из тела, которым отправитель управляет целиком, а уезжает и в ключ
|
||||
таблицы, и в записи лога; правило то же, что уже действует для имён непокрытых
|
||||
секций.
|
||||
|
||||
Ряд пульса **внутри** тренировки MUST NOT попадать в метрику `heart_rate`:
|
||||
это разные сущности хранилища. Пульс приезжает дважды — в общем потоке метрик и
|
||||
внутри тренировки, — и смешение задвоило бы ряд.
|
||||
|
||||
Сущность без `id` либо без разбираемой метки времени SHALL пропускаться со
|
||||
счётчиком, не роняя разбор остального: тело остаётся в архиве, и доставку
|
||||
подберёт пересборка, когда разбор научится её понимать. Счётчики пропусков SHALL
|
||||
доходить до учётной записи доставки, а не только до лога, — иначе ретеншен,
|
||||
решающий по базе, получит ответ «терять нечего» там, где потеряна тренировка.
|
||||
|
||||
Отсутствие покрытой секции в теле ошибкой быть MUST NOT: доставки из одних
|
||||
метрик — большинство потока.
|
||||
|
||||
#### Scenario: Тренировка разбирается вместе с маршрутом
|
||||
|
||||
- **WHEN** тело содержит `data.workouts` с тренировкой, несущей `route`
|
||||
- **THEN** разбор отдаёт сущность с идентификатором, именем, началом, концом,
|
||||
офсетом и длительностью
|
||||
- **AND** её содержимое несёт маршрут и внутренние ряды исходными байтами
|
||||
|
||||
#### Scenario: Ряд пульса тренировки не становится метрикой
|
||||
|
||||
- **WHEN** тренировка содержит `heartRateData`
|
||||
- **THEN** точки этого ряда не попадают в точки метрик
|
||||
- **AND** остаются внутри содержимого сущности
|
||||
|
||||
#### Scenario: Запись состояния разума разбирается
|
||||
|
||||
- **WHEN** тело содержит `data.stateOfMind` с элементом, несущим `id` и `start`
|
||||
- **THEN** разбор отдаёт запись с родом `stateOfMind`, идентификатором, меткой
|
||||
времени и содержимым исходными байтами
|
||||
|
||||
#### Scenario: Имя не той формы не уносит тренировку
|
||||
|
||||
- **WHEN** у тренировки с корректными `id` и `start` поле `name` приехало
|
||||
числом
|
||||
- **THEN** тренировка попадает в результат разбора с пустым именем
|
||||
- **AND** её содержимое сохраняется дословно, включая маршрут
|
||||
|
||||
#### Scenario: Конец не той формы не уносит тренировку
|
||||
|
||||
- **WHEN** у тренировки с корректными `id` и `start` поле `end` приехало числом
|
||||
- **THEN** тренировка попадает в результат разбора, а конец равен началу
|
||||
|
||||
#### Scenario: Сущность без идентификатора пропускается
|
||||
|
||||
- **WHEN** элемент покрытой секции не несёт `id`, либо `id` пуст, либо `id`
|
||||
приехал не строкой
|
||||
- **THEN** сущность в результат разбора не попадает
|
||||
- **AND** факт учитывается счётчиком, а разбор остальных сущностей продолжается
|
||||
|
||||
#### Scenario: Элемент секции не является объектом
|
||||
|
||||
- **WHEN** элемент покрытой секции не разбирается как объект JSON
|
||||
- **THEN** сущность в результат разбора не попадает
|
||||
- **AND** факт учитывается **отдельным** счётчиком, а соседние сущности
|
||||
разбираются как обычно
|
||||
|
||||
Отдельным, а не общим с «нет `id`»: доставка, где не разобрался сам элемент, —
|
||||
это сменившаяся форма секции, а доставка без `id` — сменившаяся форма
|
||||
идентификатора. Ронять из-за такого элемента всю доставку нельзя тем более:
|
||||
`failed` фоновая свёртка не подбирает никогда, и вместе с одной кривой
|
||||
тренировкой в него уехали бы записи `stateOfMind` той же доставки.
|
||||
|
||||
#### Scenario: Элемент секции не объект — свой счётчик
|
||||
|
||||
- **WHEN** элемент покрытой секции пришёл как `null`, строка, число или массив
|
||||
- **THEN** факт учитывается счётчиком «не разобралось как объект»
|
||||
- **AND** счётчик «нет `id`» не растёт
|
||||
|
||||
#### Scenario: Повтор ключа метки решается последним значением
|
||||
|
||||
- **WHEN** у элемента ключ `start` встречается дважды, и валидная метка стоит
|
||||
последней
|
||||
- **THEN** сущность попадает в результат разбора с этой меткой
|
||||
|
||||
#### Scenario: Сущность без разбираемой метки времени пропускается
|
||||
|
||||
- **WHEN** элемент покрытой секции несёт `id`, но его метка времени не
|
||||
разбирается ни одним из поддерживаемых форматов либо пришла не строкой
|
||||
(включая `null`)
|
||||
- **THEN** сущность в результат разбора не попадает
|
||||
- **AND** факт учитывается счётчиком
|
||||
- **AND** поле `date` вместо непонятого `start` не подставляется
|
||||
|
||||
#### Scenario: Сущность со слишком длинным идентификатором пропускается
|
||||
|
||||
- **WHEN** элемент покрытой секции несёт `id` длиннее предела
|
||||
- **THEN** сущность в результат разбора не попадает
|
||||
- **AND** факт учитывается тем же счётчиком, что и отсутствие `id`
|
||||
|
||||
#### Scenario: Длительность берётся из тела, а не из интервала
|
||||
|
||||
- **WHEN** тренировка несёт `duration` равный `91.746` при интервале
|
||||
`start`/`end` в 91 секунду
|
||||
- **THEN** длительность сущности равна `91.746`
|
||||
|
||||
#### Scenario: Тренировка без длительности сохраняется без неё
|
||||
|
||||
- **WHEN** тренировка не несёт `duration` либо оно не является числом
|
||||
- **THEN** сущность сохраняется, а её длительность остаётся незаполненной
|
||||
- **AND** нулём она MUST NOT становиться
|
||||
|
||||
#### Scenario: Нечитаемый конец тренировки не отбрасывает её
|
||||
|
||||
- **WHEN** тренировка несёт `end`, который не разбирается
|
||||
- **THEN** конец сущности равен её началу
|
||||
- **AND** исходное значение остаётся в содержимом дословно
|
||||
|
||||
#### Scenario: Незнакомое поле тренировки переживает разбор
|
||||
|
||||
- **WHEN** тренировка несёт поле, которого разбор не знает
|
||||
- **THEN** оно сохраняется в содержимом сущности дословно
|
||||
- **AND** разбор не завершается ошибкой
|
||||
|
||||
#### Scenario: Непокрытая секция с собственными id остаётся непокрытой
|
||||
|
||||
- **WHEN** тело содержит `data.ecg`
|
||||
- **THEN** `ecg` попадает в список непокрытых ключей
|
||||
- **AND** сущностей из неё разбор не отдаёт
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Диагностика разбора не несёт значений из тела
|
||||
|
||||
Сообщение об ошибке разбора MUST NOT содержать значений из тела доставки. Оно
|
||||
SHALL называть **тип** встреченного токена и смещение во входе — по смещению
|
||||
место находится в теле, лежащем в архиве, а значение из тела в логе не имеет
|
||||
права быть в принципе.
|
||||
|
||||
Тип SHALL называться словарём JSON (`object`, `array`, `string`, `number`,
|
||||
`bool`, `null`), а не именем типа языка реализации: имя типа для делимитера не
|
||||
говорит ничего — какая скобка встретилась вместо ожидаемой, из него не следует,
|
||||
— а сам делимитер принадлежит фиксированному набору и содержимого не раскрывает,
|
||||
поэтому печатается значением.
|
||||
|
||||
Смещение SHALL указывать на место **перед** виновным токеном и от длины его
|
||||
значения зависеть MUST NOT. Декодер сообщает позицию как конец последнего
|
||||
возвращённого токена, поэтому взятая после чтения она отличалась бы от начала
|
||||
проблемы ровно на длину значения — то есть на восемь мегабайт в том самом
|
||||
случае, ради которого требование написано, и обещание «место находится в теле»
|
||||
не выполнялось бы.
|
||||
|
||||
Это не стиль, а тот же инвариант, что уже записан для точек: данные о здоровье
|
||||
чувствительнее токенов, тела запросов пишутся только на `DEBUG` и с обрезкой.
|
||||
Подстановка токена целиком инвариант обходит: тело в 8 МиБ даёт текст ошибки в
|
||||
8 МиБ, который уходит атрибутом `error` на уровень `WARN` — то есть содержимое
|
||||
доставки оказывается в логе полностью и без обрезки.
|
||||
|
||||
Предел SHALL держаться самим сообщением, а не обрезкой на стороне
|
||||
логирующего: обрезка живёт в другом месте и о новой ошибке разбора не узнает.
|
||||
|
||||
Правило SHALL распространяться и на **чужие** причины: ошибка библиотеки разбора
|
||||
кладёт в текст литерал значения, поэтому причина, приходящая извне, обрезается по
|
||||
названной длине на границе. Тот же предел SHALL действовать на проверке формы
|
||||
конверта при приёме — она пользуется той же библиотекой, и её отказ логируется на
|
||||
`DEBUG`, где инвариант тоже требует обрезки.
|
||||
|
||||
#### Scenario: Огромное значение не доезжает до текста ошибки
|
||||
|
||||
- **WHEN** тело содержит на месте ожидаемого объекта строку в несколько
|
||||
мегабайт
|
||||
- **THEN** разбор завершается ошибкой
|
||||
- **AND** длина текста ошибки не зависит от длины этого значения
|
||||
- **AND** текст называет тип токена словарём JSON и смещение перед токеном
|
||||
- **AND** смещение не меняется, если то же значение сделать длиннее
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: База назначения пригодна к подмене
|
||||
|
||||
База назначения SHALL нести полноценный учёт доставок, а не только объекты
|
||||
витрины: подменяется файл базы **целиком**, а не одна таблица.
|
||||
|
||||
Состав переноса нормируется явно, потому что колонки `delivery` двух разных
|
||||
родов:
|
||||
|
||||
```
|
||||
факты журнала id, received_at, automation_name, automation_id, aggregation,
|
||||
period, session_id, bytes, sha256, raw_path, headers
|
||||
← переносятся дословно
|
||||
производные parse_status, points, derived_layer, uncovered_sections,
|
||||
skipped_entities ← начинаются пустыми
|
||||
```
|
||||
|
||||
Перечень производных полей SHALL пополняться **тем же изменением**, которое
|
||||
заводит новое поле: он единственное место, где сказано, чему нельзя пережить
|
||||
пересборку, и следующий автор решает по нему. Поле, не внесённое в перечень,
|
||||
однажды перенесут «для полноты учёта».
|
||||
|
||||
Факты журнала SHALL переноситься дословно, включая записи, тела которых в
|
||||
архиве уже нет. Заголовки восстановлению не подлежат ничем — в архиве их нет, —
|
||||
и неполный перенос уничтожил бы их первой же подменой, а с ними и вывод слоя
|
||||
для **всех** доставок, не только подобранных. Запись без тела при этом не
|
||||
сворачивается и в наследовании слоя не участвует: выведенного слоя у неё нет.
|
||||
|
||||
Производные от разбора поля MUST начинаться пустыми. Перенос `derived_layer`
|
||||
особенно опасен и незаметен: доставка, чей повторный разбор отказал (штатный
|
||||
исход, когда слой не выводится), сохранила бы слой **прежнего** разбора, и
|
||||
следующая доставка той же автоматизации унаследовала бы его. Витрина снова стала
|
||||
бы функцией предыдущего прогона, а не журнала, причём оба прогона были бы
|
||||
самосогласованы — проверка «повторная пересборка ничего не меняет» этого не
|
||||
ловит. Для числа пропущенных сущностей цена та же и хуже: пустота у него значит
|
||||
«не измерялось», и перенесённое число выдавало бы измерение прежнего разбора за
|
||||
измерение текущего — а по нему принимается необратимое решение об удалении тела.
|
||||
|
||||
#### Scenario: Учёт переносится полностью
|
||||
|
||||
- **WHEN** пересборка завершилась
|
||||
- **THEN** число строк учёта в базе назначения равно числу строк рабочей базы
|
||||
плюс число подобранных тел
|
||||
- **AND** заголовки перенесённых доставок совпадают с рабочей базой дословно
|
||||
|
||||
#### Scenario: Слой прошлого разбора в наследование не попадает
|
||||
|
||||
- **WHEN** в рабочей базе у доставок проставлен `derived_layer`
|
||||
- **THEN** отпечаток пересобранной витрины совпадает с отпечатком пересборки
|
||||
того же журнала из учёта без проставленных слоёв
|
||||
|
||||
#### Scenario: Число пропущенных сущностей не переносится из журнала
|
||||
|
||||
- **WHEN** в рабочей базе у доставки проставлено число пропущенных сущностей, а
|
||||
тела этой доставки в архиве уже нет
|
||||
- **THEN** в базе назначения её число пропущенных сущностей отсутствует
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Отчёт пересборки показывает удержанные версии сущностей
|
||||
|
||||
Отчёт пересборки SHALL называть число версий сущностей, удержанных правилом «не
|
||||
теряем содержания», — тем же счётчиком, что ведёт свёртка.
|
||||
|
||||
Без него правило слияния сущностей проверить нечем. Сходимость отпечатка его не
|
||||
проверяет **по построению**: живой приём и пересборка пользуются одним правилом
|
||||
и одинаково сойдутся на одинаково удержанной версии. То есть слишком строгое
|
||||
правило — например, замораживающее тренировку на старой версии из-за исчезнувшего
|
||||
ключа с пустым значением — выглядело бы как идеальная сходимость. Счётчик
|
||||
несравнимых наборов точек выведен в отчёт по ровно той же причине и тем же
|
||||
рассуждением.
|
||||
|
||||
Число SHALL печататься всегда, а не только при ненулевом значении: ноль здесь
|
||||
утверждение, а не отсутствие новостей.
|
||||
|
||||
#### Scenario: Удержанная версия видна в отчёте пересборки
|
||||
|
||||
- **WHEN** журнал содержит доставку, приехавшая версия сущности в которой
|
||||
теряет содержание сохранённой
|
||||
- **THEN** отчёт пересборки называет число удержанных версий больше нуля
|
||||
+527
@@ -0,0 +1,527 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### 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)`,
|
||||
а не порядок свёртки.** «Побеждает приехавшая» было бы функцией порядка
|
||||
свёртки, а он порядку журнала не равен: воркер сворачивает в порядке журнала
|
||||
только среди видимых ему доставок и абсолютного порядка при конкурентных
|
||||
приёмах не обещает. Доставка с более ранней меткой, свёрнутая позже, вернула бы
|
||||
витрину к недосчитанной версии, и пересборка разошлась бы с живым приёмом молча,
|
||||
в содержимом тренировки. Позиция журнала снимает это: исход зависит от журнала,
|
||||
а не от того, кто раньше добрался до базы.
|
||||
|
||||
Ровно поэтому **провенанс сущности 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** её число пропущенных сущностей отсутствует, а не равно нулю
|
||||
@@ -0,0 +1,238 @@
|
||||
## 1. Сравнение содержания сущностей (`internal/canon`)
|
||||
|
||||
- [x] 1.1 `arrayLen` → `arrayShape`: рядом с числом элементов считается число
|
||||
**содержательных** (по `isEmpty` литерала элемента, без разворачивания в
|
||||
дерево). Обход тот же, что и сегодня.
|
||||
- [x] 1.2 `Covers` получает второй разряд (все ключи `g` есть у `f`), запрет
|
||||
вырождения формы (объект/массив нельзя подменить скаляром) и сравнение
|
||||
содержательных элементов массива. Условия 3 и 4 — только для ключей,
|
||||
содержательных у `g`.
|
||||
- [x] 1.3 Комментарий `Covers` называет предел вслух: порча **внутри** элемента
|
||||
ряда не ловится ничем, кроме сверки с телом в архиве; и цену `isEmpty` на
|
||||
элементах (ряд настоящих нулей выглядит опустошённым).
|
||||
- [x] 1.4 `FormAndHash(raw) (form []byte, hash string, err error)` — форма и хеш
|
||||
одним проходом через `io.MultiWriter`; `Form`, `Hash`, `HashAll`
|
||||
переписаны через общего писателя, чтобы реализация осталась одна.
|
||||
- [x] 1.5 Тесты в `internal/canon/canon_test.go`: скелет не покрывает настоящую;
|
||||
массив из `null`/`{}` той же длины не покрывает содержательный; ключ с
|
||||
пустым значением не исчезает; форма не вырождается скаляром; покрытие
|
||||
остаётся транзитивным; `FormAndHash` совпадает с `Form`+`Hash`.
|
||||
|
||||
## 2. Каноническая форма один раз и вне транзакции (`internal/store`)
|
||||
|
||||
- [x] 2.1 `entityVersion` без ленивости: `newEntityVersion` зовёт `FormAndHash`
|
||||
один раз и держит `canon.Fields`. `analyze()` и флаг `parsed` удаляются.
|
||||
- [x] 2.2 Сохранённая версия строится своим конструктором (её содержимое
|
||||
читается внутри транзакции и только при разошедшемся хеше).
|
||||
- [x] 2.3 Комментарий `bucket.go` приводится в соответствие с тем, что код
|
||||
делает, — сегодня он утверждает обратное; остаточный предел (разбор
|
||||
сохранённой остаётся в транзакции) назван там же.
|
||||
|
||||
## 3. Победитель внутри доставки — функция множества (`internal/store`)
|
||||
|
||||
- [x] 3.1 Общий помощник выбора победителя: максимальные элементы частичного
|
||||
порядка плюс минимум по тотальному. `store.resolve` (точки) и
|
||||
`dedupeEntities` (сущности) становятся двумя его вызовами.
|
||||
`pickWithinDelivery` удаляется.
|
||||
- [x] 3.2 Порядок тотален до конца: при совпавших канонических формах решает
|
||||
минимум исходных байтов. «Строго покрыта» = покрыта другой и сама её не
|
||||
покрывает.
|
||||
- [x] 3.3 Счётчик различающихся версий считает кандидатов, чья каноническая
|
||||
форма отличается от формы победителя.
|
||||
- [x] 3.4 Тесты: перестановки трёх версий одного `id` в одном теле; две версии
|
||||
разного содержания при равных множествах ключей и длинах (счётчик не
|
||||
молчит); две версии с равной канонической формой и разными байтами
|
||||
(счётчик молчит, байты в витрине одни при любой перестановке).
|
||||
|
||||
## 4. Провенанс при совпавшем хеше (`internal/store`)
|
||||
|
||||
- [x] 4.1 При совпавшем хеше сравниваются позиции журнала; сохранённая раньше
|
||||
приехавшей — обновляются **только** колонки провенанса. `updated_at` не
|
||||
двигается: иначе он становится меткой касания строки.
|
||||
- [x] 4.2 Счётчик записанных сущностей от такого обновления не растёт;
|
||||
причина — комментарием. Обновление идемпотентно при равных позициях.
|
||||
- [x] 4.3 Тест сходимости журнала `A → B → A` с отложенной доставкой: содержимое
|
||||
и отпечаток совпадают со свёрткой в порядке журнала.
|
||||
- [x] 4.4 Тест: повторная присылка того же содержимого не двигает `updated_at`.
|
||||
|
||||
## 5. Страж версии схемы (`internal/store`)
|
||||
|
||||
- [x] 5.1 Чтение версии схемы — одной функцией и средствами goose
|
||||
(`Provider.GetVersions`), если это работает на соединении только для
|
||||
чтения; иначе ручной запрос с `sqlite_master` + `sql.NullInt64` и
|
||||
названной вслух причиной копии. Разбора текста ошибки драйвера быть не
|
||||
должно.
|
||||
- [x] 5.2 `Open` отказывает, если версия базы **выше** версии бинаря, и не
|
||||
мигрирует. `OpenForRead` сохраняет строгое равенство — не ослабляется.
|
||||
- [x] 5.3 Тесты: `Open` отказывает на базе с версией из будущего; новая база
|
||||
открывается и мигрирует; `OpenForRead` по-прежнему отказывает на базе
|
||||
старее бинаря.
|
||||
|
||||
## 6. Мягкий заголовок сущности (`internal/hae`)
|
||||
|
||||
- [x] 6.1 Тип `softString` с `UnmarshalJSON`, игнорирующим нестроковое значение;
|
||||
пять полей заголовка получают его. Теги остаются декларативными.
|
||||
- [x] 6.2 Нестроковый `start` не откатывается на `date` — он считается
|
||||
неразбираемой меткой.
|
||||
- [x] 6.3 Тесты: `name`/`end` не той формы не уносят тренировку; `id` не строкой
|
||||
и `id` длиннее предела пропускают сущность со счётчиком; метка не того
|
||||
формата и нестроковый `start` пропускают со счётчиком; элемент, который
|
||||
сам не объект, остаётся в **своём** счётчике.
|
||||
|
||||
## 7. Пропуски сущностей в учёте доставки
|
||||
|
||||
- [x] 7.1 Миграция `00008`: колонка `skipped_entities INTEGER` **без
|
||||
умолчания** — отсутствие значения означает «не измерялось» и отличимо от
|
||||
нуля.
|
||||
- [x] 7.2 `ParseOutcome` несёт число пропущенных сущностей; свёртка заполняет
|
||||
его в обоих исходах (успех и отказ, если разбор успел досчитать), замещая
|
||||
прежнее значение целиком.
|
||||
- [x] 7.3 `docs/database.md` обновлён тем же изменением.
|
||||
- [x] 7.4 Колонка внесена в реестр производных полей: комментарий
|
||||
`ListDeliveries` и дельта `specs/reindex/`. Пересборка её не переносит.
|
||||
- [x] 7.5 Тесты: доставка с пропущенной сущностью получает ненулевой счётчик;
|
||||
пересвёртка без пропусков его обнуляет; доставка, свёрнутая до появления
|
||||
счётчика, отличима от нулевой.
|
||||
- [x] 7.6 **Замер сделан до утверждения формулировок**: живой архив, 118 тел,
|
||||
пропусков `noID=0 noTime=0 malformed=0` — data-миграции не нужно, и это
|
||||
сказано числом в комментарии миграции.
|
||||
|
||||
## 8. Диагностика разбора без значений из тела (`internal/hae`)
|
||||
|
||||
- [x] 8.1 Четыре места `fmt.Errorf("… встречено %v", tok)` называют тип токена
|
||||
словарём JSON (`object`/`array`/`string`/`number`/`bool`/`null`,
|
||||
делимитер — значением) и смещение **начала** токена: `InputOffset()`
|
||||
снимается ДО `Token()`.
|
||||
- [x] 8.2 Тест: тело в мегабайты даёт текст ошибки постоянной длины.
|
||||
|
||||
## 9. Занятость базы в логе приёма (`internal/ingest`)
|
||||
|
||||
- [x] 9.1 Отказ учёта доставки различает именно `store.ErrBusy`, а не
|
||||
«обстоятельства вообще»: отмена на этом пути невозможна по построению.
|
||||
Уровень остаётся `ERROR`.
|
||||
- [x] 9.2 Тест на форму записи лога.
|
||||
|
||||
## 10. Наблюдаемость нового правила
|
||||
|
||||
- [x] 10.1 Счётчик удержанных версий сущностей выведен в отчёт пересборки
|
||||
(`internal/replay`) и печатается в `task verify:archive` — сходимость
|
||||
отпечатка это правило не проверяет по построению.
|
||||
- [x] 10.2 Замер: сколько удержаний даёт живой архив с новым правилом.
|
||||
|
||||
## 11. Документация и беклог
|
||||
|
||||
- [x] 11.1 `docs/architecture.md`: правило покрытия (четыре условия + пустота
|
||||
элемента), предел «порча внутри элемента ряда», провенанс при совпавшем
|
||||
хеше и его асимметрия с провенансом объекта, победитель внутри доставки
|
||||
как функция множества, страж версии схемы с эксплуатационной ценой,
|
||||
счётчик пропущенных сущностей, пункт 5 как единственная точка, где витрина
|
||||
не функция множества доставок.
|
||||
- [x] 11.2 `docs/conventions.md`: текст ошибки разбора без значений из тела;
|
||||
тест перестановок обязан включать версию с содержимым, равным одной из
|
||||
присланных, и пару «равная форма, разные байты»; колонка необратимого
|
||||
решения отличает ноль от «не измерялось»; метка изменения меняется только
|
||||
при изменении содержимого.
|
||||
- [x] 11.3 `docs/review-journal.md`: запись о чекпоинте кода без трёх проходов.
|
||||
- [x] 11.4 Остатки заведены задачами беклога: NULL-метка; пределы размера
|
||||
сущности и секции с потоковым расчётом; принцип отбора data-миграций;
|
||||
строка про очередь `pending` — в `stats-nablyudaemost.md`.
|
||||
|
||||
## 12. Приёмка
|
||||
|
||||
- [x] 12.1 Оракулы `tmp/adv/` переписаны под нормированные ожидания и живут
|
||||
обычными тестами пакетов. Из семи подслучаев `ОдноПоле` зеленеют два
|
||||
(`name` числом, `end` числом); пять (`id` числом, `id` длиннее предела,
|
||||
метка иного формата, метка Unix-эпохой, метки нет) остаются пропусками со
|
||||
счётчиком **по замыслу** и проверяются как пропуски.
|
||||
- [x] 12.2 `task gate` зелёный.
|
||||
- [x] 12.3 `task verify:archive` сходится на живом архиве (база: 2049 объектов,
|
||||
отпечаток `799dc2b7…`, тренировок 2, записей 2).
|
||||
|
||||
## Приёмочные критерии (рубрика ревью предложения)
|
||||
|
||||
- [x] A1 Любая перестановка порядка свёртки в пределах одного журнала даёт один
|
||||
отпечаток витрины **при нулевом счётчике несравнимых версий**; при
|
||||
ненулевом расхождение допустимо и обязано сопровождаться этим счётчиком.
|
||||
- [x] A2 Ни одно правило слияния не зависит от порядка элементов в JSON-массиве,
|
||||
включая случай равных канонических форм.
|
||||
- [x] A3 Внутри транзакции не считается ни одна каноническая форма приехавшей
|
||||
версии; пик кучи на большом теле измерен до и после.
|
||||
- [x] A4 Правило слияния тотально: для каждой пары версий назван ровно один
|
||||
исход, ветки «по умолчанию побеждает приехавшая» нет.
|
||||
- [x] A5 Каждый исход, при котором содержимое отброшено или сохранённая
|
||||
удержана, даёт счётчик; счётчик удержаний виден в отчёте пересборки.
|
||||
- [x] A6 Одно поле не того типа стоит одного поля; исключения (`id`, метка)
|
||||
названы поимённо и обоснованы.
|
||||
- [x] A7 Ни одно сообщение об ошибке и ни одна запись лога выше `DEBUG` не несут
|
||||
значений из тела доставки; длина текста ошибки от длины значения не
|
||||
зависит.
|
||||
- [x] A8 Повторная присылка того же содержимого не меняет ни витрину, ни
|
||||
отпечаток, ни счётчики записи, ни метку изменения содержимого.
|
||||
- [x] A9 Чтение хеша и провенанса сохранённой версии идёт в той же транзакции,
|
||||
в которой пишется результат.
|
||||
- [x] A10 Всё, по чему принимается необратимое решение (число пропусков),
|
||||
отличает «ноль» от «не измерялось».
|
||||
|
||||
## 13. Дозакрыто по ревью кода (профиль `deep`, девять проходов)
|
||||
|
||||
Обе регрессии, найденные враждебным проходом, подтверждены триажем прогоном
|
||||
против базы и исправлены.
|
||||
|
||||
- [x] 13.1 **Второй разряд `Covers` сделан условным** — как у `Relate` и как
|
||||
велела постановка. Безусловный вариант был регрессией: версия с
|
||||
`totalEnergy: null` и без маршрута запирала законный досчёт навсегда
|
||||
(оракул: база писала маршрут, новый код удерживал пустышку).
|
||||
- [x] 13.2 **Выбор победителя внутри доставки перестал быть квадратичным по
|
||||
числу присланных версий**: совпавшие канонические формы схлопываются до
|
||||
отбора, отбор видит отмену. Было: n=4000 — 3.9 с против 31 мс базы; тело в
|
||||
1.6 МБ перекрывало дедлайн свёртки, 64 МиБ — порядка 59 часов работы
|
||||
единственного воркера при зелёном `/healthz`. Стало: 2000 копий — 0.06 с.
|
||||
- [x] 13.3 **Отказ разбора больше не пишет «измеренный ноль» пропусков.**
|
||||
`ParseOutcome.SkippedEntities` стал указателем, колонка не трогается той
|
||||
же идиомой, что и выведенный слой.
|
||||
- [x] 13.4 Счётчики разведены: `EntitiesHeld` (удержания против сохранённой) и
|
||||
`EntitiesDiverging` (версии одного ключа в одном теле). Отдельные атрибуты
|
||||
лога, отдельные ветви WARN, две цифры в отчёте пересборки.
|
||||
- [x] 13.5 `canon.Form` перестал считать и выбрасывать SHA-256 на каждой точке
|
||||
(общий низ без хеша); `arrayShape` перестал аллоцировать копию каждого
|
||||
элемента; `shapeKept` смотрит признак «это массив» у обеих сторон.
|
||||
- [x] 13.6 Разбор сохранённой версии внутри транзакции удешевлён: только
|
||||
множества ключей, форма — лениво (замер: 4.5 мс / 2.3 МБ против 1.3 мс /
|
||||
174 КБ).
|
||||
- [x] 13.7 `softString` задаёт поле целиком и различает «ключа не было» от
|
||||
«ключ был»: `start: null` больше не уводит тренировку на момент из `date`,
|
||||
повтор ключа решается последним значением.
|
||||
- [x] 13.8 Элемент секции, не являющийся объектом (включая `null`), уходит в
|
||||
свой счётчик, а не в «нет `id`».
|
||||
- [x] 13.9 Чужая причина ошибки обрезается на границе `hae` и на проверке формы
|
||||
конверта в приёме: `UnmarshalTypeError` кладёт в текст литерал, и тело из
|
||||
миллиона цифр давало мегабайт в логе.
|
||||
- [x] 13.10 `OpenForRead` отвергает базу без журнала миграций сразу и
|
||||
структурно, а не тремя секундами попыток записи через goose.
|
||||
- [x] 13.11 `touchEntityProvenance` проверяет `RowsAffected`; ключевые аргументы
|
||||
живут одной функцией рядом с текстом `WHERE`; ветка составного ключа
|
||||
покрыта тестом.
|
||||
- [x] 13.12 Названы вслух: предел «байты от первой свёрнутой доставки при
|
||||
совпавшей форме», зависимость исхода несравнимости от порядка свёртки,
|
||||
провенанс вне отпечатка, исключение `source`, удержание памяти на всё
|
||||
время транзакции, отсутствие пути понижения схемы.
|
||||
- [x] 13.13 Заведён блокер «Чем откатывать релиз после наката миграции»;
|
||||
пополнены задачи-остатки (потолок числа версий, чтение `NULL` ретеншеном,
|
||||
источник алерта тишины).
|
||||
|
||||
## 14. Границы, оставленные сознательно
|
||||
|
||||
- Исход при **несравнимых** версиях остаётся функцией порядка свёртки, а не
|
||||
журнала: в витрине лежит победитель прошлых слияний, а не все кандидаты
|
||||
истории. Единственная точка, где витрина не функция множества доставок;
|
||||
названа в коде и в `architecture.md`, наблюдается счётчиком удержаний.
|
||||
- При совпавшей канонической форме в витрине остаются **байты** первой
|
||||
свёрнутой доставки. Содержания не теряется, отпечаток не различает,
|
||||
переписывать мегабайтный маршрут ради выбора между эквивалентными литералами
|
||||
не стали.
|
||||
- Требование постановки «`differs=true` при разных байтах» исполнено **по
|
||||
канонической форме**: побайтовый счётчик срабатывал бы на измеренной норме
|
||||
потока (нестабильный порядок ключей, дребезг последнего разряда).
|
||||
- Тренировка с датой в **незнакомом формате** по-прежнему теряется целиком —
|
||||
мягкое чтение закрывает смену типа, а не формата строки. Пропуск виден в
|
||||
учётной записи; хранение сущности с неразобранной меткой вынесено остатком.
|
||||
@@ -7,9 +7,7 @@
|
||||
принятое, что происходит с несвёрнутым при остановке и рестарте. Цена ошибки
|
||||
здесь наивысшая в проекте — доставка, не попавшая в архив и в журнал, не
|
||||
восстанавливается: телефон её не перешлёт.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Ответ приёма отражает сохранность, а не разбор
|
||||
|
||||
Приём SHALL отвечать `200` после того, как тело записано в сырой архив и
|
||||
@@ -302,3 +300,24 @@ NOT: факт уходит в `DEBUG`, приём продолжается.
|
||||
|
||||
- **WHEN** запрос идёт не на приём
|
||||
- **THEN** его бюджет записи ответа остаётся общим `write_timeout`
|
||||
|
||||
### Requirement: Отказ учёта доставки называет класс причины
|
||||
|
||||
Система SHALL логировать отказ, случившийся **после** того, как тело легло в архив, но до появления учётной записи, так, чтобы владелец отличал **занятость базы** от прочих причин. Различается именно занятость: у неё уже есть доменная ошибка, и она означает конкуренцию за запись, которая будет повторяться.
|
||||
|
||||
Расширять признак до «обстоятельств вообще» система MUST NOT, хотя предикат с таким смыслом в проекте есть: он включает ещё и отмену работы снаружи, а на этом пути отмена невозможна по построению — учёт ведётся на контексте, переживающем обрыв соединения. Назвать отменённую работу занятостью базы значило бы отправить владельца искать конкуренцию там, где её нет.
|
||||
|
||||
Уровень при этом остаётся `ERROR` независимо от класса: тело лежит в архиве без
|
||||
учётной записи, то есть осиротело, и вернуть его в журнал может только
|
||||
пересборка. Занятость базы этого не отменяет — она объясняет причину, а не
|
||||
снимает работу. Смысл различения в другом: занятость означает конкуренцию за
|
||||
запись, которая будет повторяться и лечится не тем же, чем лечится сбой диска
|
||||
или испорченная база.
|
||||
|
||||
#### Scenario: Занятая база при учёте доставки видна как отдельный класс
|
||||
|
||||
- **WHEN** запись учёта доставки не проходит из-за занятости базы
|
||||
- **THEN** отказ логируется на уровне `ERROR` вместе с путём тела в архиве
|
||||
- **AND** запись отличает занятость базы от прочих причин отказа
|
||||
- **AND** тот же отказ по другой причине этого признака не несёт
|
||||
|
||||
|
||||
@@ -465,6 +465,55 @@ JSON от HAE нестабилен, а значение уезжает в баз
|
||||
полей зависит от типа тренировки (у уличной есть `route`, `avgSpeed`,
|
||||
`flightsClimbed`, у домашней — `temperature`, `humidity`, `intensity`).
|
||||
|
||||
**Значение заголовка не того ТИПА SHALL стоить одного поля, а не сущности.**
|
||||
Каждое поле заголовка читается мягко: строка берётся, когда значение является
|
||||
строкой, и считается отсутствующей во всех прочих случаях. Правило уже записано
|
||||
для длительности («нечисловое значение — это пропуск ОДНОГО поля, а не сломанная
|
||||
сущность») и распространяется на весь заголовок. Иначе `name`, приехавшее
|
||||
числом, уносит тренировку вместе с маршрутом, а доставка при этом числится
|
||||
разобранной.
|
||||
|
||||
Мягкость MUST достигаться конструкцией, которая **не полагается на дозаполнение
|
||||
остальных полей** библиотекой разбора: `encoding/json` при несовпадении типа
|
||||
«skips that field and completes the unmarshaling as best it can», но тут же
|
||||
оговаривает, что дозаполнение полей **после** проблемного не гарантировано.
|
||||
Разбор, построенный на распознавании ошибки типа постфактум, перестал бы быть
|
||||
функцией тела: одна и та же тренировка давала бы разный заголовок.
|
||||
|
||||
Граница правила называется вслух: оно закрывает смену **типа** значения, но не
|
||||
смену **формата строки**. Наблюдавшийся дрейф — формата дат (разбор дат уже
|
||||
зависит от секции пакета), и метка в незнакомом формате по-прежнему уносит
|
||||
сущность целиком; закрыть это может только хранение сущности с неразобранной
|
||||
меткой, а это отдельная задача. Пропуск при этом перестаёт быть невидимым: он
|
||||
доходит до учётной записи доставки.
|
||||
|
||||
Идентификатор исключением из мягкости MUST быть: сущность без строкового `id`
|
||||
не адресуема, и приведение чужого нестрокового значения к строке было бы
|
||||
выдумыванием идентичности за источник. Такая сущность пропускается тем же
|
||||
счётчиком, что и сущность без `id`.
|
||||
|
||||
Началом сущности при **присутствующем, но непрочитанном** `start` подставляться
|
||||
`date` MUST NOT — включая `start: null`.
|
||||
«Значение не той формы» и «значения нет» здесь различаются: фолбэк на `date`
|
||||
существует для сущностей, у которых `start` не прислан вовсе, а подстановка
|
||||
другого поля вместо непонятого даёт метку **другого момента времени**, ничем не
|
||||
отличимую от настоящей. Такой `start` SHALL считаться неразбираемой меткой —
|
||||
тем же исходом и тем же счётчиком, что метка незнакомого формата. Различать
|
||||
надо именно «ключ был», а не «значение не той формы»: `null` тоже не даёт
|
||||
строки, и без этого различения он молча уводил бы тренировку на другой момент
|
||||
времени.
|
||||
|
||||
Мягкое чтение SHALL задавать поле целиком на каждое вхождение ключа, а не
|
||||
накапливать признаки между вызовами. JSON допускает повтор ключа с семантикой
|
||||
«побеждает последнее», и разбор ей уже следует; накопленный признак сделал бы
|
||||
заголовок функцией истории вызовов, а не тела.
|
||||
|
||||
Элемент секции, не являющийся объектом JSON, SHALL уходить в счётчик «не
|
||||
разобралось как объект» — включая `null`. Разбор в структуру на `null` ошибки не
|
||||
даёт, поэтому такой элемент без явной проверки попадал бы в счётчик «нет `id`»,
|
||||
и сменившаяся форма СЕКЦИИ диагностировалась бы как сменившаяся форма
|
||||
ИДЕНТИФИКАТОРА — ради различения которых два счётчика и заведены.
|
||||
|
||||
Длительность SHALL браться из тела, а не вычисляться из начала и конца: HAE
|
||||
шлёт `91.746` секунды при интервале в 91 секунду, и вычисленное значение молча
|
||||
разошлось бы с присланным. Отсутствие или нечисловое значение длительности
|
||||
@@ -496,7 +545,9 @@ SHALL пропускаться тем же счётчиком, что и сущ
|
||||
|
||||
Сущность без `id` либо без разбираемой метки времени SHALL пропускаться со
|
||||
счётчиком, не роняя разбор остального: тело остаётся в архиве, и доставку
|
||||
подберёт пересборка, когда разбор научится её понимать.
|
||||
подберёт пересборка, когда разбор научится её понимать. Счётчики пропусков SHALL
|
||||
доходить до учётной записи доставки, а не только до лога, — иначе ретеншен,
|
||||
решающий по базе, получит ответ «терять нечего» там, где потеряна тренировка.
|
||||
|
||||
Отсутствие покрытой секции в теле ошибкой быть MUST NOT: доставки из одних
|
||||
метрик — большинство потока.
|
||||
@@ -520,9 +571,22 @@ SHALL пропускаться тем же счётчиком, что и сущ
|
||||
- **THEN** разбор отдаёт запись с родом `stateOfMind`, идентификатором, меткой
|
||||
времени и содержимым исходными байтами
|
||||
|
||||
#### Scenario: Имя не той формы не уносит тренировку
|
||||
|
||||
- **WHEN** у тренировки с корректными `id` и `start` поле `name` приехало
|
||||
числом
|
||||
- **THEN** тренировка попадает в результат разбора с пустым именем
|
||||
- **AND** её содержимое сохраняется дословно, включая маршрут
|
||||
|
||||
#### Scenario: Конец не той формы не уносит тренировку
|
||||
|
||||
- **WHEN** у тренировки с корректными `id` и `start` поле `end` приехало числом
|
||||
- **THEN** тренировка попадает в результат разбора, а конец равен началу
|
||||
|
||||
#### Scenario: Сущность без идентификатора пропускается
|
||||
|
||||
- **WHEN** элемент покрытой секции не несёт `id` либо `id` пуст
|
||||
- **WHEN** элемент покрытой секции не несёт `id`, либо `id` пуст, либо `id`
|
||||
приехал не строкой
|
||||
- **THEN** сущность в результат разбора не попадает
|
||||
- **AND** факт учитывается счётчиком, а разбор остальных сущностей продолжается
|
||||
|
||||
@@ -539,12 +603,26 @@ SHALL пропускаться тем же счётчиком, что и сущ
|
||||
`failed` фоновая свёртка не подбирает никогда, и вместе с одной кривой
|
||||
тренировкой в него уехали бы записи `stateOfMind` той же доставки.
|
||||
|
||||
#### Scenario: Элемент секции не объект — свой счётчик
|
||||
|
||||
- **WHEN** элемент покрытой секции пришёл как `null`, строка, число или массив
|
||||
- **THEN** факт учитывается счётчиком «не разобралось как объект»
|
||||
- **AND** счётчик «нет `id`» не растёт
|
||||
|
||||
#### Scenario: Повтор ключа метки решается последним значением
|
||||
|
||||
- **WHEN** у элемента ключ `start` встречается дважды, и валидная метка стоит
|
||||
последней
|
||||
- **THEN** сущность попадает в результат разбора с этой меткой
|
||||
|
||||
#### Scenario: Сущность без разбираемой метки времени пропускается
|
||||
|
||||
- **WHEN** элемент покрытой секции несёт `id`, но его метка времени не
|
||||
разбирается ни одним из поддерживаемых форматов
|
||||
разбирается ни одним из поддерживаемых форматов либо пришла не строкой
|
||||
(включая `null`)
|
||||
- **THEN** сущность в результат разбора не попадает
|
||||
- **AND** факт учитывается счётчиком
|
||||
- **AND** поле `date` вместо непонятого `start` не подставляется
|
||||
|
||||
#### Scenario: Сущность со слишком длинным идентификатором пропускается
|
||||
|
||||
@@ -582,3 +660,47 @@ SHALL пропускаться тем же счётчиком, что и сущ
|
||||
- **THEN** `ecg` попадает в список непокрытых ключей
|
||||
- **AND** сущностей из неё разбор не отдаёт
|
||||
|
||||
### Requirement: Диагностика разбора не несёт значений из тела
|
||||
|
||||
Сообщение об ошибке разбора MUST NOT содержать значений из тела доставки. Оно
|
||||
SHALL называть **тип** встреченного токена и смещение во входе — по смещению
|
||||
место находится в теле, лежащем в архиве, а значение из тела в логе не имеет
|
||||
права быть в принципе.
|
||||
|
||||
Тип SHALL называться словарём JSON (`object`, `array`, `string`, `number`,
|
||||
`bool`, `null`), а не именем типа языка реализации: имя типа для делимитера не
|
||||
говорит ничего — какая скобка встретилась вместо ожидаемой, из него не следует,
|
||||
— а сам делимитер принадлежит фиксированному набору и содержимого не раскрывает,
|
||||
поэтому печатается значением.
|
||||
|
||||
Смещение SHALL указывать на место **перед** виновным токеном и от длины его
|
||||
значения зависеть MUST NOT. Декодер сообщает позицию как конец последнего
|
||||
возвращённого токена, поэтому взятая после чтения она отличалась бы от начала
|
||||
проблемы ровно на длину значения — то есть на восемь мегабайт в том самом
|
||||
случае, ради которого требование написано, и обещание «место находится в теле»
|
||||
не выполнялось бы.
|
||||
|
||||
Это не стиль, а тот же инвариант, что уже записан для точек: данные о здоровье
|
||||
чувствительнее токенов, тела запросов пишутся только на `DEBUG` и с обрезкой.
|
||||
Подстановка токена целиком инвариант обходит: тело в 8 МиБ даёт текст ошибки в
|
||||
8 МиБ, который уходит атрибутом `error` на уровень `WARN` — то есть содержимое
|
||||
доставки оказывается в логе полностью и без обрезки.
|
||||
|
||||
Предел SHALL держаться самим сообщением, а не обрезкой на стороне
|
||||
логирующего: обрезка живёт в другом месте и о новой ошибке разбора не узнает.
|
||||
|
||||
Правило SHALL распространяться и на **чужие** причины: ошибка библиотеки разбора
|
||||
кладёт в текст литерал значения, поэтому причина, приходящая извне, обрезается по
|
||||
названной длине на границе. Тот же предел SHALL действовать на проверке формы
|
||||
конверта при приёме — она пользуется той же библиотекой, и её отказ логируется на
|
||||
`DEBUG`, где инвариант тоже требует обрезки.
|
||||
|
||||
#### Scenario: Огромное значение не доезжает до текста ошибки
|
||||
|
||||
- **WHEN** тело содержит на месте ожидаемого объекта строку в несколько
|
||||
мегабайт
|
||||
- **THEN** разбор завершается ошибкой
|
||||
- **AND** длина текста ошибки не зависит от длины этого значения
|
||||
- **AND** текст называет тип токена словарём JSON и смещение перед токеном
|
||||
- **AND** смещение не меняется, если то же значение сделать длиннее
|
||||
|
||||
|
||||
@@ -249,10 +249,15 @@
|
||||
факты журнала id, received_at, automation_name, automation_id, aggregation,
|
||||
period, session_id, bytes, sha256, raw_path, headers
|
||||
← переносятся дословно
|
||||
производные parse_status, points, derived_layer, uncovered_sections
|
||||
← начинаются пустыми
|
||||
производные parse_status, points, derived_layer, uncovered_sections,
|
||||
skipped_entities ← начинаются пустыми
|
||||
```
|
||||
|
||||
Перечень производных полей SHALL пополняться **тем же изменением**, которое
|
||||
заводит новое поле: он единственное место, где сказано, чему нельзя пережить
|
||||
пересборку, и следующий автор решает по нему. Поле, не внесённое в перечень,
|
||||
однажды перенесут «для полноты учёта».
|
||||
|
||||
Факты журнала SHALL переноситься дословно, включая записи, тела которых в
|
||||
архиве уже нет. Заголовки восстановлению не подлежат ничем — в архиве их нет, —
|
||||
и неполный перенос уничтожил бы их первой же подменой, а с ними и вывод слоя
|
||||
@@ -265,7 +270,9 @@
|
||||
следующая доставка той же автоматизации унаследовала бы его. Витрина снова стала
|
||||
бы функцией предыдущего прогона, а не журнала, причём оба прогона были бы
|
||||
самосогласованы — проверка «повторная пересборка ничего не меняет» этого не
|
||||
ловит.
|
||||
ловит. Для числа пропущенных сущностей цена та же и хуже: пустота у него значит
|
||||
«не измерялось», и перенесённое число выдавало бы измерение прежнего разбора за
|
||||
измерение текущего — а по нему принимается необратимое решение об удалении тела.
|
||||
|
||||
#### Scenario: Учёт переносится полностью
|
||||
|
||||
@@ -280,6 +287,12 @@
|
||||
- **THEN** отпечаток пересобранной витрины совпадает с отпечатком пересборки
|
||||
того же журнала из учёта без проставленных слоёв
|
||||
|
||||
#### Scenario: Число пропущенных сущностей не переносится из журнала
|
||||
|
||||
- **WHEN** в рабочей базе у доставки проставлено число пропущенных сущностей, а
|
||||
тела этой доставки в архиве уже нет
|
||||
- **THEN** в базе назначения её число пропущенных сущностей отсутствует
|
||||
|
||||
### Requirement: Подмену рабочей базы делает человек
|
||||
|
||||
Система SHALL оставлять замену рабочей базы пересобранной человеку и
|
||||
@@ -463,3 +476,25 @@ SHALL идти в поток ошибок, а не смешиваться с о
|
||||
- **AND** команда завершается ненулевым кодом
|
||||
- **AND** файла по пути назначения не остаётся
|
||||
|
||||
### Requirement: Отчёт пересборки показывает удержанные версии сущностей
|
||||
|
||||
Отчёт пересборки SHALL называть число версий сущностей, удержанных правилом «не
|
||||
теряем содержания», — тем же счётчиком, что ведёт свёртка.
|
||||
|
||||
Без него правило слияния сущностей проверить нечем. Сходимость отпечатка его не
|
||||
проверяет **по построению**: живой приём и пересборка пользуются одним правилом
|
||||
и одинаково сойдутся на одинаково удержанной версии. То есть слишком строгое
|
||||
правило — например, замораживающее тренировку на старой версии из-за исчезнувшего
|
||||
ключа с пустым значением — выглядело бы как идеальная сходимость. Счётчик
|
||||
несравнимых наборов точек выведен в отчёт по ровно той же причине и тем же
|
||||
рассуждением.
|
||||
|
||||
Число SHALL печататься всегда, а не только при ненулевом значении: ноль здесь
|
||||
утверждение, а не отсутствие новостей.
|
||||
|
||||
#### Scenario: Удержанная версия видна в отчёте пересборки
|
||||
|
||||
- **WHEN** журнал содержит доставку, приехавшая версия сущности в которой
|
||||
теряет содержание сохранённой
|
||||
- **THEN** отчёт пересборки называет число удержанных версий больше нуля
|
||||
|
||||
|
||||
+337
-36
@@ -486,15 +486,30 @@ Apple его нет (находка 46). Поэтому список MUST сох
|
||||
за двое суток и основанием для второй транзакции не является.
|
||||
|
||||
Система SHALL хранить рядом с сущностью хеш её канонического содержимого и
|
||||
пропускать запись, если хеш не изменился. Тренировка переприсылается каждой
|
||||
доставкой автоматизации, пока не доедет маршрут: на живом архиве 44 доставленные
|
||||
копии дают три различных содержимых.
|
||||
пропускать запись содержимого, если хеш не изменился. Тренировка
|
||||
переприсылается каждой доставкой автоматизации, пока не доедет маршрут: на
|
||||
живом архиве 44 доставленные копии дают три различных содержимых.
|
||||
|
||||
Сравнение SHALL начинаться с хеша, читаемого **без** содержимого сохранённой
|
||||
сущности: маршрут доходит до мегабайта, разжимать и канонизировать его на каждой
|
||||
из 44 копий не за чем. Хеш приехавшей сущности SHALL считаться один раз на
|
||||
доставку, а не на каждой попытке повтора транзакции при занятости базы:
|
||||
канонизация материализует значение целиком, и повтор умножал бы пик кучи.
|
||||
из 44 копий не за чем.
|
||||
|
||||
Каноническая форма приехавшей сущности SHALL считаться **один раз на версию и
|
||||
до входа в транзакцию**, а хеш SHALL браться из уже посчитанной формы. Внутри
|
||||
транзакции канонизации приехавших версий быть MUST NOT: транзакция открывается
|
||||
`immediate`, то есть блокирует запись, и повторяется до пяти раз при занятости
|
||||
базы — измерено, что тело 40 МиБ даёт пик кучи 768 МиБ, а тело 63 МиБ удерживает
|
||||
блокировку 5.019 с при `busy_timeout` 5000, после чего конкурентная доставка
|
||||
исчерпывает повторы. Считать форму дважды (в хеше и в сравнении) система MUST
|
||||
NOT: это ровно та же работа над теми же байтами.
|
||||
|
||||
Остаточный предел называется вслух: разбор **сохранённой** версии остаётся
|
||||
внутри транзакции — её содержимое читается оттуда же и только когда хеш
|
||||
разошёлся. Значит удержание блокировки по-прежнему пропорционально размеру
|
||||
сохранённой сущности, и класс отказа «конкурентный приём исчерпал повторы → 500
|
||||
по доставке, тело которой уже в архиве» этим требованием **не закрывается**, а
|
||||
лишь становится различимым в логе. Закрыть его может только предел на размер
|
||||
сущности вместе с потоковым расчётом — отдельная задача.
|
||||
|
||||
#### Scenario: Тренировка хранится одной строкой с маршрутом
|
||||
|
||||
@@ -514,7 +529,8 @@ Apple его нет (находка 46). Поэтому список MUST сох
|
||||
|
||||
#### Scenario: Повторная присылка той же тренировки не пишет в базу
|
||||
|
||||
- **WHEN** приезжает тренировка, содержимое которой совпадает с сохранённым
|
||||
- **WHEN** приезжает тренировка, содержимое которой совпадает с сохранённым, и
|
||||
её доставка стоит в журнале не позже сохранённой
|
||||
- **THEN** хеш совпадает и запись не выполняется
|
||||
|
||||
#### Scenario: Отказ посреди доставки не оставляет части сущностей
|
||||
@@ -522,6 +538,13 @@ Apple его нет (находка 46). Поэтому список MUST сох
|
||||
- **WHEN** свёртка доставки прерывается на середине
|
||||
- **THEN** не записывается ни одна сущность этой доставки
|
||||
|
||||
#### Scenario: Каноническая форма сущности считается один раз
|
||||
|
||||
- **WHEN** доставка с сущностью сворачивается, и транзакция повторяется из-за
|
||||
занятости базы
|
||||
- **THEN** каноническая форма приехавшей сущности не пересчитывается ни на
|
||||
повторе, ни отдельно от хеша
|
||||
|
||||
### Requirement: Замена версии сущности не теряет содержания
|
||||
|
||||
Сущность с собственным `id` SHALL замещаться **целиком**, а не сливаться по
|
||||
@@ -535,7 +558,9 @@ Apple его нет (находка 46). Поэтому список MUST сох
|
||||
содержания** сохранённой. Порядок разбора:
|
||||
|
||||
```
|
||||
1. хеш канонического содержимого совпал → записи нет
|
||||
1. хеш канонического содержимого совпал → содержимое не пишется,
|
||||
провенанс поднимается до
|
||||
более поздней позиции журнала
|
||||
2. содержание приехавшей покрывает сохранённую
|
||||
и сверх того → приехавшая замещает целиком
|
||||
3. приехавшая теряет содержание сохранённой → остаётся сохранённая,
|
||||
@@ -546,21 +571,59 @@ Apple его нет (находка 46). Поэтому список MUST сох
|
||||
счётчик + WARN
|
||||
```
|
||||
|
||||
**Содержание сравнивается множеством ключей с непустым значением — и только им.**
|
||||
Сравнение полноты, принятое для точек, здесь неприменимо: оно гасит отношение
|
||||
включения, когда значения общих содержательных ключей разошлись, а у сущности
|
||||
они расходятся **всегда** — источник её досчитывает. Проверено: сохранённая
|
||||
тренировка с маршрутом против приехавшей без маршрута даёт «надмножество» при
|
||||
неизменных значениях и «равенство» при изменившихся, то есть на живых данных
|
||||
защита не сработала бы вовсе, а тест на фикстуре с неизменёнными значениями
|
||||
остался бы зелёным. Условия «значения общих ключей совпали» здесь быть MUST NOT.
|
||||
**Содержание сравнивается множествами ключей и формой их значений — но не
|
||||
значениями.** Сравнение полноты, принятое для точек, здесь неприменимо: оно
|
||||
гасит отношение включения, когда значения общих содержательных ключей
|
||||
разошлись, а у сущности они расходятся **всегда** — источник её досчитывает.
|
||||
Проверено: сохранённая тренировка с маршрутом против приехавшей без маршрута
|
||||
даёт «надмножество» при неизменных значениях и «равенство» при изменившихся, то
|
||||
есть на живых данных защита не сработала бы вовсе, а тест на фикстуре с
|
||||
неизменёнными значениями остался бы зелёным. Условия «значения общих ключей
|
||||
совпали» здесь быть MUST NOT.
|
||||
|
||||
Дополнительно к множеству ключей SHALL сравниваться **длина верхнеуровневых
|
||||
массивов**: усечённый маршрут (три точки вместо 593) ключа не теряет, а теряет
|
||||
95% содержимого тренировки. Досчёт ряды удлиняет, поэтому укорачивание —
|
||||
законный признак «приехало меньше». Предел правила называется вслух: сокращение
|
||||
**внутри** элемента ряда (точка маршрута без `altitude`) не ловится ничем, кроме
|
||||
сверки с телом в архиве.
|
||||
Покрытие 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 доставленные копии не случилось ни разу. Но восстановление
|
||||
@@ -576,6 +639,36 @@ Apple его нет (находка 46). Поэтому список MUST сох
|
||||
в содержимом тренировки. Позиция журнала снимает это: исход зависит от журнала,
|
||||
а не от того, кто раньше добрался до базы.
|
||||
|
||||
Ровно поэтому **провенанс сущности SHALL обновляться и тогда, когда хеш
|
||||
совпал**: сохранённая позиция журнала участвует в тай-брейке пункта 4, и если
|
||||
в ней осталась первая свёрнутая копия вместо победителя журнала, отложенная
|
||||
доставка вернёт витрину к прежнему содержимому — то есть живая витрина
|
||||
разойдётся с пересборкой. Обновление MUST касаться **только** провенанса;
|
||||
содержимое при совпавшем хеше не переписывается, счётчик записанных сущностей
|
||||
не растёт (он считает содержимое витрины, и его сравнимость с прежними замерами
|
||||
важнее учёта обновления), и метка изменения содержимого не двигается тоже:
|
||||
иначе она стала бы меткой касания строки и дребезжала бы двадцать шесть раз на
|
||||
неизменившейся тренировке, а потребитель запроса «что изменилось с момента X»
|
||||
получил бы шум, неотличимый от настоящего досчёта. Провенанс несёт собственную
|
||||
метку — времени приёма своей доставки, — и для тай-брейка её достаточно.
|
||||
|
||||
Обновление провенанса SHALL быть идемпотентным: равные позиции журнала (та же
|
||||
доставка, свёрнутая повторно) ничего не меняют.
|
||||
|
||||
Слово «провенанс» у сущности и у часового объекта означает **разное**, и это
|
||||
называется вслух: у объекта хранится доставка, **создавшая** его, и она не
|
||||
поднимается никогда; у сущности — доставка, **чья версия лежит сейчас**, и она
|
||||
поднимается до максимума по журналу среди версий с этим содержимым. Причина в
|
||||
том, что у объекта нет замещения версии целиком, а у сущности только оно и есть.
|
||||
|
||||
Чтение сохранённой версии, сравнение и запись результата SHALL идти **одной
|
||||
транзакцией**: хеш и провенанс, на которых держится весь тай-брейк, читаются
|
||||
там же, где пишется исход. Оптимистичное чтение до транзакции допустимо только
|
||||
с перепроверкой обоих внутри — иначе две конкурентные свёртки одной сущности
|
||||
прочитают одну и ту же старую позицию, обе решат «я позже», и победит та, что
|
||||
закоммитила последней: исход снова станет функцией порядка коммитов, а не
|
||||
журнала, причём молча.
|
||||
|
||||
Отличие от точки здесь содержательное: у точки на одних координатах законно
|
||||
встречаются два разных измерения, и предпочитать позднее нет оснований — там
|
||||
исход решает порядок канонических форм. У сущности `id` — идентичность одного
|
||||
@@ -583,15 +676,42 @@ Apple его нет (находка 46). Поэтому список MUST сох
|
||||
тай-брейк по канонической форме заморозил бы тренировку на произвольной из
|
||||
версий навсегда, вместе с недосчитанной энергией.
|
||||
|
||||
Две версии одного ключа **внутри одной доставки** позициями не различаются и
|
||||
SHALL разрешаться минимумом канонической формы — включая случай несравнимых
|
||||
наборов. Внутри доставки «сохранённой» версии не существует, есть только
|
||||
порядок элементов в JSON-массиве, а он нестабилен: правило «остаётся первая
|
||||
встреченная» сделало бы исход функцией порядка на проводе. Сворачиваться между
|
||||
собой такие версии SHALL до сравнения с сохранённой, а факт «в одном теле
|
||||
приехали две версии одного ключа с разным содержанием» SHALL считаться
|
||||
**симметрично**: счётчик, зависящий от порядка элементов, наблюдал бы событие
|
||||
через раз.
|
||||
Версии одного ключа **внутри одной доставки** позициями не различаются, и
|
||||
победитель среди них SHALL быть **функцией множества версий, а не порядка
|
||||
элементов массива**: сперва отбрасываются строго покрытые кем-то из остальных,
|
||||
среди оставшихся берётся минимум канонической формы. «Строго покрыта» означает
|
||||
«покрыта другой версией и сама её не покрывает»: покрытие — предпорядок, две
|
||||
версии могут покрывать друг друга взаимно, и отбрасывание всего покрытого
|
||||
опустошило бы множество, потеряв обе. Порядок при этом обязан быть **тотальным
|
||||
до конца**: при совпавших канонических формах решает минимум исходных байтов —
|
||||
иначе победителем оказывается тот, кто стоял в массиве раньше, а порядок ключей
|
||||
в JSON от HAE нестабилен, и в хранилище легли бы разные байты при одинаковом
|
||||
содержимом. Попарная свёртка здесь
|
||||
неверна ровно так же, как она была неверна для точек: покрытие — частичный
|
||||
порядок, тай-брейк — тотальный, и вместе они дают нетранзитивное отношение
|
||||
победы, при котором `[A,B,C]` и `[B,C,A]` дают разных победителей, а порядок
|
||||
элементов в JSON-массиве нестабилен. Сворачиваться между собой такие версии
|
||||
SHALL до сравнения с сохранённой.
|
||||
|
||||
Факт «в одном теле приехали две версии одного ключа с разным содержанием» SHALL
|
||||
считаться **симметрично** и тоже быть функцией множества: считаются кандидаты,
|
||||
чья каноническая форма отличается от формы победителя. Счётчик этот SHALL быть
|
||||
ОТДЕЛЬНЫМ от счётчика удержаний: две версии в одном теле содержания не теряют —
|
||||
победитель ложится в витрину целиком, — и одно число на два события отвечало бы
|
||||
ни на одно. На счётчик удержаний опирается единственный контроль того, что
|
||||
правило покрытия не стало слишком строгим; примесь делает его неотличимым от
|
||||
шума.
|
||||
|
||||
Версии с совпавшей канонической формой SHALL схлопываться ДО выбора победителя.
|
||||
Выбор квадратичен по числу кандидатов, а их число приходит из чужого тела; без
|
||||
схлопывания тело в пределах приёма занимает свёртку на часы. Отбор SHALL видеть
|
||||
отмену: иначе дедлайн свёртки, заведённый ровно против зависшей работы, не
|
||||
значит ничего. Побайтовое различие при
|
||||
совпавшей канонической форме событием MUST NOT считаться — порядок ключей в
|
||||
JSON от HAE нестабилен и дребезг последнего разряда double тоже, так что
|
||||
счётчик по байтам срабатывал бы на измеренной норме потока. Различие
|
||||
**содержимого** при совпадающих множествах ключей и длинах массивов считаться
|
||||
SHALL: сегодня ровно этот случай даёт ноль и молчащий счётчик.
|
||||
|
||||
Поля версий MUST NOT объединяться: несравнимые наборы (приехавшая принесла
|
||||
новые ключи и потеряла старые) разрешаются в пользу сохранённой и считаются
|
||||
@@ -600,11 +720,11 @@ SHALL разрешаться минимумом канонической фор
|
||||
наблюдение.
|
||||
|
||||
Исход SHALL быть функцией журнала в его порядке. Остаточный предел называется
|
||||
вслух: слияние попарное — сохранённая против приехавшей, — поэтому при
|
||||
несравнимых наборах (пункт 5) исход зависит от порядка проигрывания. Тот же
|
||||
предел есть у часового объекта, где хранится победитель прошлых слияний, а не
|
||||
все кандидаты истории; пункты 2–4 от порядка свёртки не зависят, а пункт 5
|
||||
сопровождается счётчиком и `WARN`.
|
||||
вслух: сравнение сохранённой с приехавшей попарно — в витрине лежит победитель
|
||||
прошлых слияний, а не все кандидаты истории, — поэтому при несравнимых наборах
|
||||
(пункт 5) исход зависит от порядка проигрывания. Тот же предел есть у часового
|
||||
объекта; пункты 1–4 от порядка свёртки не зависят, а пункт 5 сопровождается
|
||||
счётчиком и `WARN`.
|
||||
|
||||
#### Scenario: Доехавший маршрут замещает тренировку без маршрута
|
||||
|
||||
@@ -639,12 +759,73 @@ SHALL разрешаться минимумом канонической фор
|
||||
- **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** две витрины различаются только тем, где проходит граница между родом
|
||||
@@ -708,3 +889,123 @@ SHALL разрешаться минимумом канонической фор
|
||||
- **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** её число пропущенных сущностей отсутствует, а не равно нулю
|
||||
|
||||
|
||||
Reference in New Issue
Block a user