Files
av 8331328134 Дозакрыты находки ревью по слиянию сущностей
- Правило покрытия получило второй разряд (условный, как у точек), запрет
  вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и
  ряд из null больше не затирают маршрут. Победитель внутри доставки стал
  функцией множества версий — общим помощником с точками, — а провенанс
  поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину
  к прежнему содержимому.
- Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной
  записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма
  считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает
  на старте; текст ошибки разбора не несёт значений из тела.
- Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты:
  безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя
  был квадратичен по числу присланных версий одного ключа.
2026-08-02 16:38:18 +03:00

375 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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`
как единственная точка, где витрина не является функцией множества доставок.