diff --git a/docs/architecture.md b/docs/architecture.md index 2f52b45..7922b1e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -831,8 +831,12 @@ value_code "HKCategoryValueSleepAnalysisAsleepREM" ← выведено по с только `stateOfMind`, а `ecg`, `symptoms`, `cycleTracking`, `medications` и `heartRateNotifications` остаются непокрытыми **намеренно**: живой поток не приносил их ни разу, их формы никто не видел, а полнота покрытия HealthKit ради -полноты целью проекта не является. Модель под них заложена — новая секция -добавляется одной строкой в множество покрытых имён, а не миграцией. +полноты целью проекта не является. Модель под них заложена: **миграции схемы** +новая секция не требует — она добавляется строкой в множество покрытых имён. +Но не одной: правило «покрыли секцию — пересверните» (выше) требует ещё и +data-миграции, переводящей уже принятые `partial`-доставки с этим ключом в +`pending`, а после появления ретеншена — строки в перечне невосстановимого. +Три места, и первое из них — не самое важное. Ключ записи — **пара**, а не один `id`: собственный `id` наблюдался живьём только у `stateOfMind`, где он UUID, и короткий несквозной идентификатор в двух diff --git a/docs/backlog/README.md b/docs/backlog/README.md index 647bbdd..c37cc92 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -21,6 +21,7 @@ - [Порядок журнала при конкурентных приёмах](poryadok-zhurnala-na-priyome.md) — доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда — живое состояние расходится с reindex ## высокий +- [Дозакрыть находки ревью по слиянию сущностей](dozakryt-nahodki-sushchnostej.md) — Скелет из null затирает маршрут необратимо, а откат бинаря поверх новой схемы проходит молча: семь находок с прогнанными оракулами - [Измеренный род агрегации и каталог разрезов](rod-agregacii-i-katalog.md) — Без рода метрики свёртка в ответе неотличима от угадывания — а суммировать нижний слой значит завысить втрое - [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может - [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате diff --git a/docs/backlog/dozakryt-nahodki-sushchnostej.md b/docs/backlog/dozakryt-nahodki-sushchnostej.md new file mode 100644 index 0000000..ac9eb7b --- /dev/null +++ b/docs/backlog/dozakryt-nahodki-sushchnostej.md @@ -0,0 +1,125 @@ +# Дозакрыть находки ревью по слиянию сущностей + +**Приоритет:** высокий + +Задача «Тренировки и секции с собственными id» (`f8200f7`) прошла ревью не +полностью: проходы `adversary`, `ops` и архитектурный на коде не запускались. +Дозапуск принёс девять причин, триаж оставил семь. Оба заявленных `critical` +понижены до `major` с названной причиной — вход недостижим из штатного потока +HAE (корпус в 118 доставок такого не производил), — но остались в работе: +потеря маршрута необратима, а `reindex` проигрывает то же поражение. + +Отчёт триажа с прогнанными оракулами — `tmp/triage-late.md`, оракулы — +`tmp/adv/*.go`. Каждый пункт ниже имеет падающий тест; правка считается +сделанной, когда соответствующий оракул зеленеет **и** переезжает из `tmp/` в +обычные тесты пакета. + +## Что делать + +**1. Скелет не затирает маршрут.** `canon.Fields.Covers` проверяет только +наличие ключа и длину верхнеуровневого массива, поэтому версия, где каждый +массив заменён массивом той же длины из `null`, а каждое не-массивное значение +— скаляром, признаётся равной настоящей и по тай-брейку журнала замещает её. +Оракул: `-run 'Скелет|Маршрут|ДвеВерсии'`. + +Решение принято, новое не проектируем: применяем к сущностям **тот же +стандарт, что записан для точек** — второй разряд сравнения, как у +`canon.Relate` («иначе ключ с пустым значением исчезает по жребию»). Плюс +запрет вырождения формы: покрывающая версия не может подменить объект или +массив скаляром — проверка по верхнему уровню, стоимость O(ключей). +Поэлементная содержательность массивов **отвергнута ценой**: пункт 4 измерил +768 МиБ пика на канонизации, полный обход маршрута на каждое сравнение эту цену +умножит. Остающийся предел — порча *внутри* элемента ряда (точка маршрута без +`altitude`) — не закрывается ничем, кроме сверки с телом в архиве, и должен +быть записан в `architecture.md` рядом с описанием `Covers` так же прямо, как +он записан в комментарии кода. + +Отдельно: `pickWithinDelivery` при равном содержании и **разных байтах** обязан +считать `differs=true` — сейчас две версии одного `id` в одном теле дают +`удержано=0` и молчащий счётчик. + +**2. Одно поле не той формы не уносит сущность.** `entityHead` держит +`ID`/`Name`/`Date`/`Start`/`End` типизированными строками, поэтому смена типа +любого из пяти роняет `json.Unmarshal` целиком, а доставка при этом получает +`parsed` с пустым списком непокрытого. Достижимо из реального потока: дрейф +формата дат у HAE задокументирован. Оракул: `-run ОдноПоле`. + +Читать пять полей через `json.RawMessage` и извлекать мягко — это буквально +принцип, уже записанный в коде для `Duration` («нечисловое значение — это +пропуск ОДНОГО поля, а не сломанная сущность»). Плюс пропуски обязаны быть +видны в **учётной записи** доставки, а не только в логе: ретеншен решает по +базе, и сегодня он получит ответ «терять нечего». Хранение сущности с +неразобранной меткой (NULL) в эту задачу не входит — см. остаток ниже. + +**3. Откат бинаря не проходит молча.** `store.Open` мигрирует безусловно и не +сверяет версию схемы, поэтому старый бинарь успешно стартует поверх схемы 7, +молча игнорирует незнакомые секции и помечает доставки разобранными. Оракул +прогнан живьём: `-run СтарыйБинарь`. Перенести в `Open` страж из +`OpenForRead` — прецедент записан там же: «расхождение версий — отказ, а не +повод мигрировать». + +**4. Канонизация — за транзакцию, по-настоящему.** Комментарий +`bucket.go:143-146` утверждает, что канонизация вынесена наружу; фактически +`analyze()` вызывается из `compareEntities` **внутри** `inTx`, который открывает +`immediate` и повторяет до пяти раз, а кеш `analyze()` пишется в **копию** +элемента среза и не переживает даже одной попытки. Измерено: тело 40 МиБ → пик +768.3 МиБ; 63 МиБ → блокировка удерживается 5.019 с при `busy_timeout` 5000, то +есть конкурентный `CreateDelivery` исчерпывает повторы и приём отвечает 500 по +доставке, тело которой уже на диске. + +В этой задаче: вынести `analyze()` наружу по-настоящему, кешировать в срезе, а +не в копии, и различать в логе `delivery failed` занятость базы (`store.ErrBusy` +уже выделен доменной ошибкой) от прочих причин. Пределы на размер сущности и +потоковый расчёт хеша — остатком. + +**5. Значения из тела не попадают в текст ошибки.** `fmt.Errorf("… встречено +%v", tok)` подставляет токен целиком: тело 8 МиБ даёт текст ошибки 8 МиБ, +который уходит атрибутом `error` на уровень `WARN`. Инвариант «тела запросов +только на `DEBUG` и с обрезкой» нарушен буквально. Называть тип токена и +`dec.InputOffset()`. Оракул: `-run Тело`. Дефект в базе диффа, не внесён +разбором сущностей. + +**6. Победитель внутри доставки — функция множества, а не порядка.** +Попарная свёртка частичного порядка с тотальным тай-брейком нетранзитивна: +`[A,B,C]` даёт `C`, `[B,C,A]` даёт `A`. Стандарт «победитель — функция множества +точек, а не порядка» записан в `architecture.md` для точек и для сущностей +молча не применён. Собрать версии ключа, отбросить строго покрытые, среди +оставшихся взять минимум канонической формы. Оракул: `-run ПорядокВнутри`. + +**7. Провенанс обновляется при равных хешах.** Совпал хеш — запись +пропускается вместе с провенансом, и в `delivery_id`/`delivery_received_at` +остаётся первая свёрнутая копия, а не победитель по журналу. Провенанс +устаревает на каждой из ~26 повторных присылок гарантированно; расхождение +живой витрины с `reindex` латентно (требует возврата содержимого к прежнему — +корпус такого не производил), но нарушает записанный инвариант детерминизма. +Сравнивать позиции в журнале и обновлять провенанс. Оракул: `-run Порядок`. + +## Что уходит остатком + +- хранение сущности с `id`, но неразобранной меткой (NULL-метка): требует схемы + и правил чтения, а после пункта 2 случай становится редким; +- пределы на размер одной сущности и суммарный размер секции, потоковый расчёт + канонической формы и хеша — заводится задачей вместе с условием из пункта 4; +- принцип «data-миграции не отбирают строки по спискам, которые где-то + обрезаются» (миграция `00007` отбирает по обрезаемому на 32 + `uncovered_sections`; для неё дефект пустой — HAE шлёт одну секцию за + доставку, — но следующая покрытая секция унаследует слепую зону); +- длина очереди `pending` в `/stats` **и без WARN**: после миграции, переводящей + доставки в `pending`, отставание по конструкции не WARN-ится (`startupDone`), + и бэклог идёт молча при зелёном `/healthz` — строка уходит в + [наблюдаемость](stats-nablyudaemost.md). + +## Кандидаты в конвенции + +- текст ошибки разбора не содержит значений из тела — только тип токена и + смещение; +- тест перестановок правила слияния обязан включать версию с содержимым, равным + одной из уже присланных: тест трёх версий с разными хешами ветку равенства не + посещает ни разу. + +Готово, когда все семь оракулов зелены, живут обычными тестами пакетов, а +`task verify:archive` сходится. + +Связано: `internal/canon`, `internal/store/entity.go`, `internal/hae/entity.go`, +`docs/review-journal.md` (пропуск проходов на чекпоинте — отклонение процесса, +ему там место).