беклог: задача на дозакрытие находок ревью сущностей

- три пропущенных прохода дозапущены, триаж оставил семь причин
- обе развилки разобраны по уже записанным решениям проекта:
  второй разряд Covers как у Relate, мягкое чтение полей как у Duration
- уточнена формулировка в architecture.md про цену покрытия секции
This commit is contained in:
av
2026-08-02 13:38:34 +03:00
parent 958d4fe970
commit 51a5272c96
3 changed files with 132 additions and 2 deletions
+6 -2
View File
@@ -831,8 +831,12 @@ value_code "HKCategoryValueSleepAnalysisAsleepREM" ← выведено по с
только `stateOfMind`, а `ecg`, `symptoms`, `cycleTracking`, `medications` и
`heartRateNotifications` остаются непокрытыми **намеренно**: живой поток не
приносил их ни разу, их формы никто не видел, а полнота покрытия HealthKit ради
полноты целью проекта не является. Модель под них заложена — новая секция
добавляется одной строкой в множество покрытых имён, а не миграцией.
полноты целью проекта не является. Модель под них заложена: **миграции схемы**
новая секция не требует — она добавляется строкой в множество покрытых имён.
Но не одной: правило «покрыли секцию — пересверните» (выше) требует ещё и
data-миграции, переводящей уже принятые `partial`-доставки с этим ключом в
`pending`, а после появления ретеншена — строки в перечне невосстановимого.
Три места, и первое из них — не самое важное.
Ключ записи — **пара**, а не один `id`: собственный `id` наблюдался живьём
только у `stateOfMind`, где он UUID, и короткий несквозной идентификатор в двух
+1
View File
@@ -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) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
@@ -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` (пропуск проходов на чекпоинте — отклонение процесса,
ему там место).