Дозакрыты находки ревью по слиянию сущностей

- Правило покрытия получило второй разряд (условный, как у точек), запрет
  вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и
  ряд из null больше не затирают маршрут. Победитель внутри доставки стал
  функцией множества версий — общим помощником с точками, — а провенанс
  поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину
  к прежнему содержимому.
- Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной
  записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма
  считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает
  на старте; текст ошибки разбора не несёт значений из тела.
- Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты:
  безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя
  был квадратичен по числу присланных версий одного ключа.
This commit is contained in:
av
2026-08-02 16:38:18 +03:00
parent 51a5272c96
commit 8331328134
52 changed files with 4921 additions and 481 deletions
@@ -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` при разных байтах» исполнено **по
канонической форме**: побайтовый счётчик срабатывал бы на измеренной норме
потока (нестабильный порядок ключей, дребезг последнего разряда).
- Тренировка с датой в **незнакомом формате** по-прежнему теряется целиком —
мягкое чтение закрывает смену типа, а не формата строки. Пропуск виден в
учётной записи; хранение сущности с неразобранной меткой вынесено остатком.