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

- Правило покрытия получило второй разряд (условный, как у точек), запрет
  вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и
  ряд из 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
+179 -41
View File
@@ -41,7 +41,52 @@ const SignificantDigits = 12
// числа округлены до SignificantDigits значащих цифр.
//
// Форма предназначена для сравнения и хеширования, а не для хранения.
//
// Возвращаемый срез принадлежит вызывающему целиком: буфер, в котором форма
// собрана, наружу больше не показывается.
func Form(raw []byte) ([]byte, error) {
return form(raw)
}
// Hash возвращает шестнадцатеричный SHA-256 канонической формы.
//
// Хеш — детектор изменений, а не ключ: совпал с сохранённым, значит писать
// нечего. Именно это делает широкие проходы синхронизации дешёвыми — глубокий
// проход переприсылает неделю, но почти все сравнения сходятся.
func Hash(raw []byte) (string, error) {
f, err := form(raw)
if err != nil {
return "", err
}
return hashOf(f), nil
}
// FormAndHash отдаёт каноническую форму и её хеш ЗА ОДИН проход.
//
// Нужен тем, кому требуется и то, и другое: сущность хешируется ради
// хеш-детектора и канонизируется ради сравнения полноты, и считать форму дважды
// над теми же байтами значит платить дважды за самую дорогую операцию
// хранилища (тело 40 МиБ даёт пик кучи 768 МиБ).
//
// Отдельной функции «хеш по готовой форме» здесь нет намеренно: она вводила бы
// контракт очерёдности, в котором передача сырых байт вместо формы даёт
// правдоподобный, но неверный хеш, а компилятор такую подмену не ловит.
//
// Обратной ошибки — «Form считает хеш и выбрасывает» — здесь тоже нет: общий
// низ у трёх функций один и хеша не считает. Иначе каждая точка при каждом
// слиянии платила бы SHA-256, который никто не смотрит: Form зовётся из SortKey
// на каждый кандидат координаты, из HashAll на каждую точку часа и дважды на
// каждое сравнение в Equal.
func FormAndHash(raw []byte) ([]byte, string, error) {
f, err := form(raw)
if err != nil {
return nil, "", err
}
return f, hashOf(f), nil
}
// form — общий низ: каноническая форма и ничего сверх неё.
func form(raw []byte) ([]byte, error) {
v, err := decode(raw)
if err != nil {
return nil, err
@@ -54,18 +99,9 @@ func Form(raw []byte) ([]byte, error) {
return buf.Bytes(), nil
}
// Hash возвращает шестнадцатеричный SHA-256 канонической формы.
//
// Хеш — детектор изменений, а не ключ: совпал с сохранённым, значит писать
// нечего. Именно это делает широкие проходы синхронизации дешёвыми — глубокий
// проход переприсылает неделю, но почти все сравнения сходятся.
func Hash(raw []byte) (string, error) {
form, err := Form(raw)
if err != nil {
return "", err
}
func hashOf(form []byte) string {
sum := sha256.Sum256(form)
return hex.EncodeToString(sum[:]), nil
return hex.EncodeToString(sum[:])
}
// HashAll возвращает хеш канонической формы последовательности значений —
@@ -246,8 +282,7 @@ func (f Fields) Relate(g Fields) Fullness {
return FullnessEqual
}
// Covers говорит, несёт ли f всё СОДЕРЖАНИЕ g: каждый содержательный ключ g
// есть у f, и ни один верхнеуровневый массив не стал короче.
// Covers говорит, несёт ли f всё СОДЕРЖАНИЕ g.
//
// Отдельно от Relate, и это не дубль. Relate гасит отношение включения до
// FullnessEqual, когда значения общих содержательных ключей разошлись, — верно
@@ -259,57 +294,160 @@ func (f Fields) Relate(g Fields) Fullness {
// (95% её веса), а тест на фикстуре с неизменёнными значениями остался бы
// зелёным.
//
// Длина верхнеуровневых массивов сравнивается потому, что усечённый маршрут
// (три точки вместо 593) ключа не теряет. Досчёт ряды удлиняет, поэтому
// укорачивание — законный признак «приехало меньше». Предел правила назван
// вслух: сокращение ВНУТРИ элемента ряда (точка маршрута без altitude) не
// ловится ничем, кроме сверки с телом в архиве.
// Условий четыре, все по ВЕРХНЕМУ уровню:
//
// Длины считаются здесь, а не в Analyze: Analyze зовётся на каждый кандидат
// слияния точек, и разбор heartbeatSeries на каждой точке стоил бы дороже
// самого сравнения.
// 1. каждый содержательный ключ g есть у f и содержателен;
//
// 2. если множества содержательных ключей СОВПАЛИ — каждый ключ g, даже
// пустой, есть у f. Тот же второй разряд, что у Relate, и с тем же
// условием: иначе ключ с пустым значением исчезает по жребию тай-брейка.
//
// Условность разряда проверена оракулом, а не выведена. Безусловный
// вариант («строже — значит правильнее») оказался хуже: версия с
// `totalEnergy: null` и без маршрута запирала законный досчёт навсегда —
// приехавшая теряла пустой ключ, сохранённая теряла содержательный
// `route`, и пара становилась несравнимой. Маршрут не доезжал НИКОГДА, и
// пересборка проигрывала то же поражение. Второй разряд разрешает спор
// равных, а не отменяет первый;
//
// 3. форма значения не вырождается: где у g объект — у f объект, где массив —
// массив. Без этого «скелет» (каждый вложенный объект заменён числом)
// признаётся равным настоящей тренировке и выигрывает тай-брейк журнала;
//
// 4. верхнеуровневый массив не теряет ни длины, ни СОДЕРЖАТЕЛЬНЫХ элементов:
// усечённый маршрут (три точки вместо 593) ключа не теряет, а маршрут из
// [null,null,null] не теряет и длины. Досчёт ряды удлиняет, поэтому и
// укорачивание, и опустошение элементов — законные признаки «приехало
// меньше».
//
// Условия 3 и 4 применяются к ключам, содержательным у g: у пустоты формы нет,
// и требовать её сохранения значило бы отличать `[]` от `0` там, где ни то, ни
// другое ничего не несёт.
//
// Содержательность элемента ряда — ТА ЖЕ пустота, что у поля (isEmpty): второй
// словарь пустоты дал бы два ответа на один вопрос. Цена названа вслух: ряд
// настоящих нулей ([0,0,0]) считается лишённым содержания, поэтому версия с ним
// сохранённую не заместит. Ошибка направлена в безопасную сторону — правило
// удерживает, а не затирает, и событие видно счётчиком; наблюдённые ряды HAE
// состоят из объектов.
//
// Предел правила назван вслух и не закрывается: сокращение ВНУТРИ элемента ряда
// (точка маршрута без altitude при непустом элементе и той же длине) не ловится
// ничем, кроме сверки с телом в архиве. Поэлементная сверка содержимого
// отвергнута ценой: она разворачивала бы каждый элемент маршрута в дерево
// значений на каждое сравнение, а тело 40 МиБ уже даёт 768 МиБ пика.
//
// Поле `source` в множества не входит (см. Analyze) — исключение придумано для
// точек, где оно измерено, и наследуется сущностью молча. Названо здесь потому,
// что список исключений живёт в Analyze: правка ради точек изменит и правило
// удержания сущностей, а ни один тест сущностей этого не заметит.
//
// Формы и длины считаются здесь, а не в Analyze: Analyze зовётся на каждый
// кандидат слияния точек, и разбор heartbeatSeries на каждой точке стоил бы
// дороже самого сравнения.
func (f Fields) Covers(g Fields) bool {
for k, gv := range g.full {
fv, ok := f.full[k]
if !ok {
return false
}
gn, gok := arrayLen(gv)
if !gok {
continue
if !shapeKept(fv, gv) {
return false
}
fn, fok := arrayLen(fv)
if !fok || fn < gn {
}
// Первый разряд пройден. Второй включается ТОЛЬКО при равенстве множеств
// содержательных ключей: если f несёт содержание сверх g, спор уже решён в
// её пользу, и пустой ключ его не отменяет.
if len(f.full) != len(g.full) {
return true
}
for k := range g.all {
if _, ok := f.all[k]; !ok {
return false
}
}
return true
}
// arrayLen возвращает число элементов верхнеуровневого массива. Второй возврат
// — является ли значение массивом вообще.
// shapeKept говорит, сохраняет ли значение fv форму и наполнение gv.
func shapeKept(fv, gv json.RawMessage) bool {
switch literalKind(gv) {
case kindObject:
return literalKind(fv) == kindObject
case kindArray:
// Третий возврат смотрится У ОБЕИХ сторон. Неразобравшийся массив у g
// дал бы нули, то есть покрывался бы даже пустым `[]`. Из тела HAE это
// недостижимо (значения приходят разобранным JSON), но сохранённая
// версия приезжает сюда из `payload` базы, а вторым источником сущностей
// планируется импорт родного экспорта Apple — там байты формирует другой
// код.
gTotal, gFull, gok := arrayShape(gv)
fTotal, fFull, fok := arrayShape(fv)
return gok && fok && fTotal >= gTotal && fFull >= gFull
default:
// Скаляр покрывается чем угодно: у f может быть и объект — это форма
// богаче, а не беднее.
return true
}
}
// literalKind — род значения по первому байту литерала, как это делает сам
// сканер encoding/json. Материализовать значение ради рода незачем.
type literalKindT int
const (
kindScalar literalKindT = iota
kindObject
kindArray
)
func literalKind(raw json.RawMessage) literalKindT {
lit := bytes.TrimSpace(raw)
if len(lit) == 0 {
return kindScalar
}
switch lit[0] {
case '{':
return kindObject
case '[':
return kindArray
default:
return kindScalar
}
}
// arrayShape возвращает число элементов верхнеуровневого массива и число
// СОДЕРЖАТЕЛЬНЫХ среди них. Третий возврат — является ли значение массивом.
//
// Элементы проглатываются в выбрасываемый RawMessage: считать нужно только
// количество, а материализация маршрута в дерево значений стоила бы того же,
// от чего отказался разбор тела.
func arrayLen(raw json.RawMessage) (int, bool) {
if len(bytes.TrimSpace(raw)) == 0 || bytes.TrimSpace(raw)[0] != '[' {
return 0, false
// Элементы проглатываются в выбрасываемый RawMessage: материализация маршрута в
// дерево значений стоила бы того же, от чего отказался разбор тела. Проверка
// пустоты идёт по литералу элемента и обхода не добавляет — он уже здесь был
// ради счёта.
func arrayShape(raw json.RawMessage) (total, contentful int, ok bool) {
if literalKind(raw) != kindArray {
return 0, 0, false
}
dec := json.NewDecoder(bytes.NewReader(raw))
if _, err := dec.Token(); err != nil { // открывающая скобка
return 0, false
return 0, 0, false
}
n := 0
// Буфер объявлен НАД циклом: RawMessage.UnmarshalJSON делает
// `append((*m)[0:0], data...)`, то есть переиспользует ёмкость. Объявление
// внутри цикла обнуляло бы срез каждый виток и давало аллокацию на элемент —
// маршрут в 593 точки стоил бы 593 аллокаций на каждую проверку покрытия,
// притом что комментарий выше обещает обратное.
var elem json.RawMessage
for dec.More() {
var skip json.RawMessage
if err := dec.Decode(&skip); err != nil {
return 0, false
if err := dec.Decode(&elem); err != nil {
return 0, 0, false
}
total++
if !isEmpty(elem) {
contentful++
}
n++
}
return n, true
return total, contentful, true
}
// agreeOnShared говорит, совпадают ли значения ключей, содержательных у обеих