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

- Правило покрытия получило второй разряд (условный, как у точек), запрет
  вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и
  ряд из 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 говорит, совпадают ли значения ключей, содержательных у обеих
+158
View File
@@ -447,3 +447,161 @@ func FuzzForm(f *testing.F) {
}
})
}
// covers — сахар для таблиц ниже: Covers работает на разобранных множествах.
func covers(a, b string) bool {
return canon.Analyze([]byte(a)).Covers(canon.Analyze([]byte(b)))
}
// Покрытие — отношение «не потеряем содержания», и проверяется оно по четырём
// условиям сразу. Оракулы взяты из враждебного прохода ревью: тело, которым
// отправитель управляет целиком, строится так, чтобы пройти проверку и вынести
// маршрут — 95% содержимого тренировки, которого нет в экспорте Apple.
func TestCoversЧетыреУсловия(t *testing.T) {
t.Parallel()
const (
// Настоящая тренировка (форма — из testdata/workout_indoor.json).
real = `{"id":"w7","name":"В помещении Ходьба","isIndoor":true,
"maxHeartRate":{"qty":199,"units":"count/min"},
"heartRate":{"max":{"qty":199},"avg":{"qty":47.2}},
"heartRateData":[{"Max":199,"Avg":86.1},{"Max":150,"Avg":80.0}],
"activeEnergy":[{"qty":49.4},{"qty":12.1}],
"totalEnergy":{"qty":66.4},"duration":11.1}`
// «Скелет»: те же имена ключей, те же длины массивов, содержания нет.
skeleton = `{"id":"w7","name":"x","isIndoor":false,
"maxHeartRate":1,"heartRate":1,
"heartRateData":[null,null],"activeEnergy":[null,null],
"totalEnergy":1,"duration":1}`
route3 = `{"id":"w9","route":[{"lat":1,"lon":10},{"lat":2},{"lat":3}]}`
routeNull3 = `{"id":"w9","route":[null,null,null]}`
routeEmpty = `{"id":"w9","route":[{},{},{}]}`
routeShort = `{"id":"w9","route":[{"lat":1,"lon":10}]}`
withEmpty = `{"id":"w9","qty":10,"context":null}`
noEmpty = `{"id":"w9","qty":10}`
richer = `{"id":"w9","qty":10,"context":null,"stepCount":900}`
)
cases := []struct {
name string
a, b string
want bool
}{
{"скелет не покрывает настоящую", skeleton, real, false},
{"настоящая покрывает скелет", real, skeleton, true},
{"ряд из null не покрывает содержательный", routeNull3, route3, false},
{"ряд из пустых объектов не покрывает содержательный", routeEmpty, route3, false},
{"содержательный ряд покрывает пустой той же длины", route3, routeNull3, true},
{"усечённый ряд не покрывает полный", routeShort, route3, false},
{"ключ с пустым значением не исчезает", noEmpty, withEmpty, false},
{"версия с пустым ключом покрывает версию без него", withEmpty, noEmpty, true},
{"более полная покрывает", richer, withEmpty, true},
{"менее полная не покрывает", withEmpty, richer, false},
{"версия покрывает саму себя", real, real, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := covers(c.a, c.b); got != c.want {
t.Errorf("Covers = %v, ожидалось %v", got, c.want)
}
})
}
}
// Запрет вырождения формы: покрывающая версия не может подменить объект или
// массив скаляром. Обратное разрешено — объект вместо скаляра богаче формой.
func TestCoversЗапретВырожденияФормы(t *testing.T) {
t.Parallel()
cases := []struct {
name string
a, b string
want bool
}{
{"скаляр не покрывает объект", `{"hr":1}`, `{"hr":{"qty":199}}`, false},
{"скаляр не покрывает массив", `{"hr":1}`, `{"hr":[{"qty":199}]}`, false},
{"объект не покрывает массив", `{"hr":{"qty":1}}`, `{"hr":[{"qty":1}]}`, false},
{"массив не покрывает объект", `{"hr":[{"qty":1}]}`, `{"hr":{"qty":1}}`, false},
{"объект покрывает скаляр", `{"hr":{"qty":1}}`, `{"hr":1}`, true},
{"строка покрывает число", `{"hr":"x"}`, `{"hr":1}`, true},
{"пустой ключ формы не требует", `{"hr":0,"id":"a"}`, `{"hr":[],"id":"a"}`, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := covers(c.a, c.b); got != c.want {
t.Errorf("Covers = %v, ожидалось %v", got, c.want)
}
})
}
}
// Покрытие — частичный порядок, и на транзитивности стоит выбор победителя из
// МНОЖЕСТВА версий: без неё «непревзойдённые» определены неоднозначно, и
// победитель становится функцией порядка элементов на проводе.
func TestCoversТранзитивно(t *testing.T) {
t.Parallel()
versions := []string{
`{"id":"w","a":1}`,
`{"id":"w","a":1,"b":null}`,
`{"id":"w","a":1,"b":2}`,
`{"id":"w","a":1,"b":2,"c":[1,2]}`,
`{"id":"w","a":1,"b":2,"c":[1,2,3]}`,
`{"id":"w","a":1,"b":2,"c":[null,null,null]}`,
`{"id":"w","a":1,"c":3}`,
`{"id":"w"}`,
}
for i, x := range versions {
for j, y := range versions {
if !covers(x, y) {
continue
}
for k, z := range versions {
if !covers(y, z) {
continue
}
if !covers(x, z) {
t.Errorf("нетранзитивно: %d ⊇ %d ⊇ %d, но %d не покрывает %d", i, j, k, i, k)
}
}
}
}
}
// Форма и хеш обязаны быть одной функцией: сравнение по одной канонизации и
// хеширование по другой разошлись бы молча, а хеш-детектор превратился бы в
// перезапись недели каждым глубоким проходом.
func TestFormAndHashСовпадаетСОтдельнымиВызовами(t *testing.T) {
t.Parallel()
raws := []string{
`{"qty":1.50,"date":"2025-06-05 07:00:00 +0300"}`,
`{"b":[1,2,{"z":null}],"a":"строка"}`,
`[1,2,3]`,
`null`,
}
for _, raw := range raws {
form, hash, err := canon.FormAndHash([]byte(raw))
if err != nil {
t.Fatalf("FormAndHash(%s): %v", raw, err)
}
wantForm, err := canon.Form([]byte(raw))
if err != nil {
t.Fatalf("Form(%s): %v", raw, err)
}
wantHash, err := canon.Hash([]byte(raw))
if err != nil {
t.Fatalf("Hash(%s): %v", raw, err)
}
if !bytes.Equal(form, wantForm) {
t.Errorf("форма разошлась: %s против %s", form, wantForm)
}
if hash != wantHash {
t.Errorf("хеш разошёлся: %s против %s", hash, wantHash)
}
}
if _, _, err := canon.FormAndHash([]byte(`{"qty":`)); err == nil {
t.Error("усечённый JSON принят за корректный")
}
}