Files
healthlog/docs/backlog/dozakryt-nahodki-sushchnostej.md
T
av 51a5272c96 беклог: задача на дозакрытие находок ревью сущностей
- три пропущенных прохода дозапущены, триаж оставил семь причин
- обе развилки разобраны по уже записанным решениям проекта:
  второй разряд Covers как у Relate, мягкое чтение полей как у Duration
- уточнена формулировка в architecture.md про цену покрытия секции
2026-08-02 13:38:34 +03:00

11 KiB
Raw Blame History

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

Приоритет: высокий

Задача «Тренировки и секции с собственными 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 — строка уходит в наблюдаемость.

Кандидаты в конвенции

  • текст ошибки разбора не содержит значений из тела — только тип токена и смещение;
  • тест перестановок правила слияния обязан включать версию с содержимым, равным одной из уже присланных: тест трёх версий с разными хешами ветку равенства не посещает ни разу.

Готово, когда все семь оракулов зелены, живут обычными тестами пакетов, а task verify:archive сходится.

Связано: internal/canon, internal/store/entity.go, internal/hae/entity.go, docs/review-journal.md (пропуск проходов на чекпоинте — отклонение процесса, ему там место).