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

- Правило покрытия получило второй разряд (условный, как у точек), запрет
  вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и
  ряд из 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
+409 -7
View File
@@ -3,11 +3,25 @@ package store_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/store"
)
func fingerprint(t *testing.T, st *store.Store) string {
t.Helper()
fp, err := st.Fingerprint(context.Background())
if err != nil {
t.Fatalf("отпечаток: %v", err)
}
return fp
}
// workout собирает тренировку с заданным содержимым. Заголовок в этих тестах
// вторичен: правило замены смотрит на содержание, а не на колонки.
func workout(t *testing.T, id, raw string) store.IncomingEntity {
@@ -206,22 +220,32 @@ func TestMergeПовторТойЖеТренировкиНеПишет(t *testin
}
}
// Три версии в разных порядках подачи: пункты правила, не зависящие от порядка
// Версии в разных порядках подачи: пункты правила, не зависящие от порядка
// свёртки, обязаны давать одно состояние. Конвенция требует перестановки трёх,
// а не пары: попарная свёртка уже давала нетранзитивную победу на точках.
func TestMergeПерестановкаТрёхВерсийДаётОдноСостояние(t *testing.T) {
// а не пары (попарная свёртка уже давала нетранзитивную победу на точках) и
// версии с содержимым, равным одной из присланных, — иначе ветка «содержание
// равно» не посещается ни разу.
func TestMergeПерестановкаВерсийДаётОдноСостояние(t *testing.T) {
t.Parallel()
type step struct {
d store.DeliveryRef
raw string
}
// Четвёртая версия несёт содержимое, РАВНОЕ одной из уже присланных. Без неё
// перебор троек с разными хешами ветку «содержание равно» не посещает ни
// разу — а именно на ней провенанс устаревал, и живая витрина расходилась с
// пересборкой молча.
steps := []step{
{from(t, "d1", "2025-06-05T08:00:00Z"), woNoRouteNewValues},
{from(t, "d2", "2025-06-05T08:05:00Z"), woWithRoute},
{from(t, "d3", "2025-06-05T08:10:00Z"), woRicher},
{from(t, "d4", "2025-06-05T08:15:00Z"), woWithRoute},
}
orders := [][]int{
{0, 1, 2, 3}, {3, 2, 1, 0}, {1, 0, 3, 2},
{2, 3, 0, 1}, {1, 3, 0, 2}, {3, 0, 2, 1},
}
orders := [][]int{{0, 1, 2}, {2, 1, 0}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {0, 2, 1}}
var want string
for i, order := range orders {
@@ -421,12 +445,18 @@ func TestMergeНесравнимыеВерсииВОдномТелеНеЗави
if storedRaw(t, прямой, "w1") != storedRaw(t, обратный, "w1") {
t.Error("исход зависит от порядка элементов в массиве секции")
}
if a.EntitiesHeld != b.EntitiesHeld {
t.Errorf("счётчик зависит от порядка: %d против %d", a.EntitiesHeld, b.EntitiesHeld)
if a.EntitiesDiverging != b.EntitiesDiverging {
t.Errorf("счётчик зависит от порядка: %d против %d", a.EntitiesDiverging, b.EntitiesDiverging)
}
if a.EntitiesHeld == 0 {
if a.EntitiesDiverging == 0 {
t.Error("две версии с разным содержанием в одном теле остались незамеченными")
}
// Удержаний тут нет: победитель лёг в витрину целиком, терять нечего.
// Счётчики разведены именно ради этого различия.
if a.EntitiesHeld != 0 || b.EntitiesHeld != 0 {
t.Errorf("несравнимые версии в одном теле посчитаны удержаниями: %d и %d",
a.EntitiesHeld, b.EntitiesHeld)
}
}
// Отпечаток обязан различать состояния, а не только содержимое: составной ключ
@@ -458,3 +488,375 @@ func TestFingerprintРазличаетСоставнойКлючЗаписи(t *
t.Error("два разных состояния витрины дали один отпечаток: составной ключ склеен до взятия длины")
}
}
// Оракулы враждебного прохода ревью: тело контролирует отправитель целиком, и
// «версия той же формы без содержания» проходила все проверки — а маршрут это
// 95% тренировки, которого в экспорте Apple нет вовсе. Восстановить его после
// затирания не может даже пересборка: журнал проиграет то же поражение.
const (
woRealWorkout = `{"id":"w7","name":"В помещении Ходьба","isIndoor":true,
"maxHeartRate":{"qty":199,"units":"count/min"},
"heartRate":{"max":{"qty":199},"avg":{"qty":47.2},"min":{"qty":41}},
"heartRateData":[{"Max":199,"Avg":86.1,"Min":41},{"Max":150,"Avg":80.0,"Min":44}],
"activeEnergy":[{"qty":49.4,"units":"kJ"},{"qty":12.1,"units":"kJ"}],
"totalEnergy":{"qty":66.4,"units":"kJ"},"duration":11.1}`
woSkeleton = `{"id":"w7","name":"x","isIndoor":false,
"maxHeartRate":1,"heartRate":1,
"heartRateData":[null,null],"activeEnergy":[null,null],
"totalEnergy":1,"duration":1}`
woNulledRoute = `{"id":"w1","name":"На улице Ходьба","route":[null,null,null],
"activeEnergy":[{"qty":10}],"totalEnergy":{"qty":20,"units":"kJ"}}`
woEmptyRoute = `{"id":"w1","name":"На улице Ходьба","route":[{},{},{}],
"activeEnergy":[{"qty":10}],"totalEnergy":{"qty":20,"units":"kJ"}}`
// Тот же смысл, другой порядок ключей и другая запись числа: каноническая
// форма совпадает, байты — нет.
woSameFormOtherBytes = `{"name":"На улице Ходьба","id":"w1","totalEnergy":{"units":"kJ","qty":20.0},
"activeEnergy":[{"qty":10}],"route":[{"lat":1},{"lat":2},{"lat":3}]}`
)
func TestMergeСкелетНеВытесняетТренировку(t *testing.T) {
t.Parallel()
st := open(t)
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w7", woRealWorkout))
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w7", woSkeleton))
if got := storedRaw(t, st, "w7"); !strings.Contains(got, "199") {
t.Error("тренировка заменена скелетом из скаляров и null")
}
if stats.EntitiesHeld != 1 {
t.Errorf("удержано %d, ожидалось 1: событие обязано быть видно", stats.EntitiesHeld)
}
}
func TestMergeМаршрутНеЗатираетсяПустышкамиТойЖеДлины(t *testing.T) {
t.Parallel()
for name, poor := range map[string]string{"null": woNulledRoute, "пустые объекты": woEmptyRoute} {
t.Run(name, func(t *testing.T) {
t.Parallel()
st := open(t)
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", poor))
if got := storedRaw(t, st, "w1"); !strings.Contains(got, `"lat":1`) {
t.Error("маршрут затёрт рядом той же длины без содержания")
}
if stats.EntitiesHeld != 1 {
t.Errorf("удержано %d, ожидалось 1", stats.EntitiesHeld)
}
})
}
}
// Ключ с пустым значением исчезал бы по жребию тай-брейка. Второй разряд
// сравнения записан для точек и здесь применяется к сущностям.
func TestMergeКлючСПустымЗначениемНеИсчезает(t *testing.T) {
t.Parallel()
const (
withEmpty = `{"id":"w1","qty":10,"context":null}`
noEmpty = `{"id":"w1","qty":11}`
)
st := open(t)
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", withEmpty))
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", noEmpty))
if got := storedRaw(t, st, "w1"); !strings.Contains(got, "context") {
t.Error("ключ с пустым значением исчез по жребию")
}
if stats.EntitiesHeld != 1 {
t.Errorf("удержано %d, ожидалось 1", stats.EntitiesHeld)
}
}
// Две версии одного `id` в ОДНОМ теле с разным содержанием: счётчик не имеет
// права молчать. До правки он давал ноль — то есть событие проходило бы как
// штатное INFO.
func TestMergeДвеВерсииВОдномТелеСчитаются(t *testing.T) {
t.Parallel()
d := from(t, "d1", "2025-06-05T08:00:00Z")
st := open(t)
stats := mergeWorkouts(t, st, d, workout(t, "w1", woWithRoute), workout(t, "w1", woNulledRoute))
if stats.EntitiesDiverging == 0 {
t.Error("две версии одного id в одном теле — счётчик молчит")
}
if got := storedRaw(t, st, "w1"); !strings.Contains(got, `"lat":1`) {
t.Error("в теле победила версия без содержания")
}
}
// Побайтовое различие при совпавшей канонической форме событием не является:
// порядок ключей у HAE нестабилен и дребезг последнего разряда тоже. Но байты в
// витрине обязаны быть одни при любой перестановке — иначе исход зависит от
// порядка элементов на проводе.
func TestMergeРавнаяФормаРазныеБайтыНеСобытие(t *testing.T) {
t.Parallel()
d := from(t, "d1", "2025-06-05T08:00:00Z")
прямой := open(t)
s1 := mergeWorkouts(t, прямой, d, workout(t, "w1", woWithRoute), workout(t, "w1", woSameFormOtherBytes))
обратный := open(t)
s2 := mergeWorkouts(t, обратный, d, workout(t, "w1", woSameFormOtherBytes), workout(t, "w1", woWithRoute))
if s1.EntitiesDiverging != 0 || s2.EntitiesDiverging != 0 {
t.Errorf("счётчик сработал на дребезге записи: %d и %d",
s1.EntitiesDiverging, s2.EntitiesDiverging)
}
if storedRaw(t, прямой, "w1") != storedRaw(t, обратный, "w1") {
t.Error("в витрине разные байты при одинаковом содержимом: победитель зависит от порядка")
}
}
// Победитель внутри доставки — функция МНОЖЕСТВА версий. Попарная свёртка
// частичного порядка с тотальным тай-брейком нетранзитивна: [A,B,C] давало C,
// [B,C,A] давало A.
func TestMergeПерестановкаТрёхВерсийВОдномТеле(t *testing.T) {
t.Parallel()
const (
vA = `{"a":[9,9],"b":2,"id":"k"}`
vB = `{"a":[1,2],"id":"k"}`
vC = `{"a":[1],"c":3,"id":"k"}`
)
orders := [][]string{
{vA, vB, vC}, {vA, vC, vB}, {vB, vA, vC},
{vB, vC, vA}, {vC, vA, vB}, {vC, vB, vA},
}
var want string
for i, order := range orders {
st := open(t)
ws := make([]store.IncomingEntity, 0, len(order))
for _, raw := range order {
ws = append(ws, workout(t, "k", raw))
}
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), ws...)
got := storedRaw(t, st, "k")
if i == 0 {
want = got
continue
}
if got != want {
t.Errorf("порядок %d дал другого победителя:\n %s\n %s", i, got, want)
}
}
}
// Провенанс обязан отражать победителя ЖУРНАЛА, а не первую свёрнутую копию.
// Иначе доставка, свёрнутая с опозданием, вернёт витрину к прежнему содержимому,
// и живая витрина разойдётся с пересборкой молча — в содержимом тренировки.
func TestMergeОтложеннаяДоставкаНеВозвращаетПрежнееСодержимое(t *testing.T) {
t.Parallel()
journal := open(t)
mergeWorkouts(t, journal, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
mergeWorkouts(t, journal, from(t, "d3", "2025-06-05T08:05:00Z"), workout(t, "w1", woSameShapeNewValues))
mergeWorkouts(t, journal, from(t, "d2", "2025-06-05T08:10:00Z"), workout(t, "w1", woWithRoute))
deferred := open(t)
mergeWorkouts(t, deferred, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
mergeWorkouts(t, deferred, from(t, "d2", "2025-06-05T08:10:00Z"), workout(t, "w1", woWithRoute))
mergeWorkouts(t, deferred, from(t, "d3", "2025-06-05T08:05:00Z"), workout(t, "w1", woSameShapeNewValues))
if a, b := storedRaw(t, journal, "w1"), storedRaw(t, deferred, "w1"); a != b {
t.Errorf("состояние зависит от порядка свёртки:\n %s\n %s", a, b)
}
if a, b := fingerprint(t, journal), fingerprint(t, deferred); a != b {
t.Errorf("отпечатки разошлись: %s против %s", a, b)
}
}
// Провенанс поднимается до более поздней доставки журнала даже при совпавшем
// хеше — иначе тай-брейк «содержание равно» решает по устаревшей позиции.
func TestMergeПовторОбновляетПровенанс(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := open(t)
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute))
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woWithRoute))
if stats.WorkoutsWritten != 0 {
t.Errorf("записано %d, ожидалось 0: содержимое то же", stats.WorkoutsWritten)
}
w, err := st.Workout(ctx, "w1")
if err != nil {
t.Fatalf("чтение тренировки: %v", err)
}
if w.Delivery != "d2" {
t.Errorf("провенанс %q, ожидался d2: победитель журнала — более поздняя доставка", w.Delivery)
}
}
// Провенанс записи обновляется тем же путём, что и у тренировки, но ключ у неё
// СОСТАВНОЙ — а значит текст `WHERE` и хвост аргументов обязаны совпадать. Ветка
// не покрывалась ни одним тестом, притом что промах давал бы `UPDATE` в ноль
// строк, невидимый ни в отпечатке (провенанса там нет), ни в счётчиках.
// Род `record` — единственный, где цена необратима: в экспорте Apple его нет.
func TestMergeПровенансЗаписиОбновляетсяПоСоставномуКлючу(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := open(t)
rec := func() store.IncomingEntity {
return store.IncomingEntity{
ID: "e1", Kind: "stateOfMind",
Start: ts(t, "2025-06-05T18:00:00Z"),
End: ts(t, "2025-06-05T18:00:00Z"),
Raw: json.RawMessage(`{"id":"e1","kind":"momentary_emotion","valence":0.5}`),
}
}
merge := func(d store.DeliveryRef) store.MergeStats {
t.Helper()
s, err := st.Merge(ctx, store.Incoming{Records: []store.IncomingEntity{rec()}}, d)
if err != nil {
t.Fatalf("слияние записи: %v", err)
}
return s
}
merge(from(t, "d1", "2025-06-05T20:00:00Z"))
stats := merge(from(t, "d2", "2025-06-05T20:05:00Z"))
if stats.RecordsWritten != 0 {
t.Errorf("записано %d, ожидалось 0: содержимое то же", stats.RecordsWritten)
}
got, err := st.Record(ctx, "stateOfMind", "e1")
if err != nil {
t.Fatalf("чтение записи: %v", err)
}
if got.Delivery != "d2" {
t.Errorf("провенанс %q, ожидался d2", got.Delivery)
}
}
// Число версий одного ключа приходит из чужого тела, а выбор победителя по ним
// квадратичен. Отмена обязана прерывать отбор: без неё тело с двадцатью
// тысячами версий занимает единственного воркера свёртки дольше, чем длится его
// собственный дедлайн, и очередь встаёт молча при зелёном `/healthz`.
func TestMergeОтменаПрерываетВыборПобедителя(t *testing.T) {
t.Parallel()
st := open(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
ws := make([]store.IncomingEntity, 0, 3)
for i := range 3 {
raw := fmt.Sprintf(`{"id":"w1","qty":%d,"route":[{"lat":%d}]}`, i, i)
ws = append(ws, workout(t, "w1", raw))
}
_, err := st.Merge(ctx, store.Incoming{Workouts: ws}, from(t, "d1", "2025-06-05T08:00:00Z"))
if !errors.Is(err, context.Canceled) {
t.Errorf("слияние дало %v, ожидалась отмена", err)
}
}
// Версии с совпавшей канонической формой схлопываются ДО квадратичного отбора:
// иначе тело, вмещающее сотни тысяч копий одного `id`, стоит часов работы. При
// этом схлопывание не имеет права менять исход — победитель тот же.
func TestMergeРавныеФормыСхлопываютсяДоОтбора(t *testing.T) {
t.Parallel()
st := open(t)
ws := make([]store.IncomingEntity, 0, 2000)
for range 2000 {
ws = append(ws, workout(t, "w1", woWithRoute))
}
ws = append(ws, workout(t, "w1", woRicher))
done := make(chan store.MergeStats, 1)
go func() {
done <- mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), ws...)
}()
select {
case stats := <-done:
if got := storedRaw(t, st, "w1"); got != woRicher {
t.Errorf("схлопывание изменило победителя:\n%s", got)
}
// Победитель — единственная более полная версия; от неё формой
// отличаются две тысячи копий победнее, и схлопывание их не прячет:
// счётчик считает версии, а не группы.
if stats.EntitiesDiverging != 2000 {
t.Errorf("различающихся версий %d, ожидалось 2000", stats.EntitiesDiverging)
}
case <-time.After(20 * time.Second):
t.Fatal("слияние не уложилось в 20 с: схлопывание не работает")
}
}
// Регрессия, найденная враждебным проходом ревью: безусловный второй разряд
// правила покрытия запирал законный досчёт НАВСЕГДА. Версия с пустым ключом и
// без маршрута оказывалась несравнимой с версией, у которой маршрут приехал, а
// этого ключа нет, — и маршрут не доезжал ни одной доставкой, причём пересборка
// проигрывала то же поражение. Второй разряд разрешает спор равных, а не
// отменяет первый.
func TestMergeПустойКлючНеЗапираетДосчёт(t *testing.T) {
t.Parallel()
const (
сПустымКлючом = `{"id":"w2","activeEnergy":[{"qty":10}],"totalEnergy":null}`
сМаршрутом = `{"id":"w2","activeEnergy":[{"qty":10}],"route":[{"lat":1},{"lat":2},{"lat":3}]}`
)
st := open(t)
mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w2", сПустымКлючом))
stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w2", сМаршрутом))
if got := storedRaw(t, st, "w2"); !strings.Contains(got, `"lat":1`) {
t.Errorf("маршрут не доехал — досчёт заперт пустым ключом:\n%s", got)
}
if stats.WorkoutsWritten != 1 {
t.Errorf("записано %d, ожидалась 1", stats.WorkoutsWritten)
}
if stats.EntitiesHeld != 0 {
t.Errorf("удержано %d: законный досчёт принят за потерю содержания", stats.EntitiesHeld)
}
}
// Единственная точка, где витрина НЕ является функцией множества доставок, —
// несравнимые версии. Тест не чинит это, а закрепляет: в витрине лежит
// победитель прошлых слияний, а не все кандидаты истории, поэтому «сохранённая
// выигрывает» даёт разный итог при разном порядке. Порядок свёртки журналу не
// равен: доставка, получившая ErrBusy, остаётся `pending` и сворачивается
// следующим проходом.
//
// Предел назван в pickEntity и в architecture.md; наблюдается счётчиком
// удержаний. Если он когда-нибудь будет закрыт, красный тест напомнит, что
// текст в обоих местах пора переписать.
func TestMergeНесравнимыеВерсииЗависятОтПорядкаСвёртки(t *testing.T) {
t.Parallel()
const (
база = `{"id":"w3","activeEnergy":[{"qty":10}]}`
сТреком = `{"id":"w3","activeEnergy":[{"qty":10}],"route":[{"lat":1},{"lat":2}]}`
сЭтажами = `{"id":"w3","activeEnergy":[{"qty":10}],"flightsClimbed":{"qty":3}}`
)
d1 := from(t, "d1", "2025-06-05T08:00:00Z")
d2 := from(t, "d2", "2025-06-05T08:05:00Z")
d3 := from(t, "d3", "2025-06-05T08:10:00Z")
журнальный := open(t)
mergeWorkouts(t, журнальный, d1, workout(t, "w3", база))
mergeWorkouts(t, журнальный, d2, workout(t, "w3", сТреком))
mergeWorkouts(t, журнальный, d3, workout(t, "w3", сЭтажами))
отложенный := open(t)
mergeWorkouts(t, отложенный, d1, workout(t, "w3", база))
mergeWorkouts(t, отложенный, d3, workout(t, "w3", сЭтажами))
stats := mergeWorkouts(t, отложенный, d2, workout(t, "w3", сТреком))
a, b := storedRaw(t, журнальный, "w3"), storedRaw(t, отложенный, "w3")
if a == b {
t.Fatal("предел закрылся — перепиши текст в pickEntity и architecture.md")
}
// И главное: расхождение не молчит.
if stats.EntitiesHeld == 0 {
t.Error("расхождение по порядку свёртки не отражено счётчиком удержаний")
}
}