Дозакрыты находки ревью по слиянию сущностей
- Правило покрытия получило второй разряд (условный, как у точек), запрет вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и ряд из null больше не затирают маршрут. Победитель внутри доставки стал функцией множества версий — общим помощником с точками, — а провенанс поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину к прежнему содержимому. - Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает на старте; текст ошибки разбора не несёт значений из тела. - Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты: безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя был квадратичен по числу присланных версий одного ключа.
This commit is contained in:
+179
-41
@@ -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 говорит, совпадают ли значения ключей, содержательных у обеих
|
||||
|
||||
@@ -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 принят за корректный")
|
||||
}
|
||||
}
|
||||
|
||||
+63
-12
@@ -117,7 +117,7 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
|
||||
}
|
||||
stats = Stats{}
|
||||
err = fmt.Errorf("%w: %v", ErrPanicked, r) //nolint:errorlint // причину раскрываем текстом, sentinel — для ветвления
|
||||
s.fail(ctx, deliveryID, err, nil)
|
||||
s.fail(ctx, deliveryID, err, parseResidue{})
|
||||
}()
|
||||
|
||||
d, err := s.store.DeliveryForParse(ctx, deliveryID)
|
||||
@@ -131,7 +131,7 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
|
||||
|
||||
body, err := s.readBody(d.RawPath)
|
||||
if err != nil {
|
||||
s.fail(ctx, deliveryID, err, nil)
|
||||
s.fail(ctx, deliveryID, err, parseResidue{})
|
||||
return stats, err
|
||||
}
|
||||
|
||||
@@ -149,7 +149,11 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
|
||||
// Список непокрытых секций переживает отказ: доставка, у которой не
|
||||
// определился слой, обязана остаться записью о том, что в теле есть
|
||||
// невосстановимая секция.
|
||||
s.fail(ctx, deliveryID, err, parsed.Uncovered)
|
||||
//
|
||||
// А вот число пропущенных сущностей — НЕ переживает: разбор, вернувший
|
||||
// ошибку, отдаёт нулевые счётчики по построению, а не по измерению, и
|
||||
// записать этот ноль значило бы объявить доставку проверенной.
|
||||
s.fail(ctx, deliveryID, err, residueOf(parsed))
|
||||
return stats, err
|
||||
}
|
||||
|
||||
@@ -171,7 +175,12 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
|
||||
ReceivedAt: d.ReceivedAt,
|
||||
})
|
||||
if err != nil {
|
||||
s.fail(ctx, deliveryID, err, parsed.Uncovered)
|
||||
// Здесь разбор досчитал: отказало слияние. Значит счётчик пропусков
|
||||
// измерен и обязан дойти до учёта — в отличие от ветки выше.
|
||||
s.fail(ctx, deliveryID, err, parseResidue{
|
||||
uncovered: parsed.Uncovered,
|
||||
skipped: skippedEntities(parsed),
|
||||
})
|
||||
return stats, err
|
||||
}
|
||||
stats.MergeStats = merge
|
||||
@@ -184,10 +193,11 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
|
||||
status = store.ParsePartial
|
||||
}
|
||||
out := store.ParseOutcome{
|
||||
Status: status,
|
||||
Points: int64(stats.Points),
|
||||
Layer: stats.Layer,
|
||||
Uncovered: parsed.Uncovered,
|
||||
Status: status,
|
||||
Points: int64(stats.Points),
|
||||
Layer: stats.Layer,
|
||||
Uncovered: parsed.Uncovered,
|
||||
SkippedEntities: skippedEntities(parsed),
|
||||
}
|
||||
if err := s.finish(ctx, deliveryID, out); err != nil {
|
||||
s.log.ErrorContext(ctx, "delivery fold failed", "error", err, "delivery_id", deliveryID)
|
||||
@@ -237,6 +247,7 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
|
||||
"records", st.Records,
|
||||
"records_written", st.RecordsWritten,
|
||||
"entities_held", st.EntitiesHeld,
|
||||
"entities_diverging", st.EntitiesDiverging,
|
||||
"skipped_entities", skippedEntities,
|
||||
"layer", st.Layer,
|
||||
"layer_mismatch", st.LayerMismatch,
|
||||
@@ -255,6 +266,9 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
|
||||
if len(st.HeldAt) > 0 {
|
||||
attrs = append(attrs, "held_at", formatEntityRefs(st.HeldAt))
|
||||
}
|
||||
if len(st.DivergingAt) > 0 {
|
||||
attrs = append(attrs, "diverging_at", formatEntityRefs(st.DivergingAt))
|
||||
}
|
||||
|
||||
// Доставка, у которой отброшены ВСЕ точки, — это сломавшийся формат, а не
|
||||
// штатная работа. Без этого условия смена формата метки выглядела бы как
|
||||
@@ -270,6 +284,14 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
|
||||
// за отказ объединять поля: событие обязано быть видно, потому что на
|
||||
// живом потоке оно не наступало ни разу и правило держится на этом.
|
||||
s.log.WarnContext(ctx, "delivery folded, poorer entity version held", attrs...)
|
||||
case st.EntitiesDiverging > 0:
|
||||
// Событие другого рода и с другим лечением: в одном теле приехали
|
||||
// версии одного ключа с разным содержанием. Победитель лёг в витрину
|
||||
// целиком, терять нечего — но корпус такого не производил, и молчать
|
||||
// об этом нельзя. Отдельной ветвью, а не общей с удержанием: сообщение
|
||||
// «удержана обеднённая версия» отправляло бы владельца искать то, чего
|
||||
// не случилось.
|
||||
s.log.WarnContext(ctx, "delivery folded, entity versions diverge in one body", attrs...)
|
||||
case allEntitiesSkipped:
|
||||
s.log.WarnContext(ctx, "delivery folded, all entities skipped", attrs...)
|
||||
case st.UncoveredDropped > 0:
|
||||
@@ -349,7 +371,35 @@ func (s *Service) finish(ctx context.Context, deliveryID string, out store.Parse
|
||||
// большое тело, исчерпанный дедлайн — свойства самой доставки, и повторять их
|
||||
// бесполезно: статус `failed`, тело ждёт пересборки. Приём при этом не
|
||||
// затрагивается: сохранили значит приняли.
|
||||
func (s *Service) fail(ctx context.Context, deliveryID string, cause error, uncovered []string) {
|
||||
// parseResidue — то, что разбор успел узнать о доставке до отказа и что обязано
|
||||
// пережить его в учёте: список непокрытых секций и число пропущенных сущностей.
|
||||
//
|
||||
// Структурой, а не двумя параметрами: у `fail` их стало бы четыре, и следующий
|
||||
// счётчик неизбежно перепутали бы местами с предыдущим. Пустое значение —
|
||||
// «разбор до этого не дошёл», и оно честно: отказ на чтении тела ничего о
|
||||
// содержимом не знает.
|
||||
type parseResidue struct {
|
||||
uncovered []string
|
||||
// skipped — nil означает «разбор до конца не дошёл, пропусков никто не
|
||||
// считал». Ноль означал бы «проверено, терять нечего», а по этому числу
|
||||
// ретеншен принимает необратимое решение об удалении тела.
|
||||
skipped *int64
|
||||
}
|
||||
|
||||
func residueOf(parsed hae.Result) parseResidue {
|
||||
return parseResidue{uncovered: parsed.Uncovered}
|
||||
}
|
||||
|
||||
// skippedEntities — сколько сущностей с собственным `id` разбор пропустил.
|
||||
// Сумма трёх классов, а не три колонки: ретеншен спрашивает «есть ли что
|
||||
// терять», а не «почему», а разбор класса живёт в логе свёртки, где все три
|
||||
// счётчика идут атрибутами.
|
||||
func skippedEntities(parsed hae.Result) *int64 {
|
||||
n := int64(parsed.SkippedNoID + parsed.SkippedEntityNoTime + parsed.SkippedEntityMalformed)
|
||||
return &n
|
||||
}
|
||||
|
||||
func (s *Service) fail(ctx context.Context, deliveryID string, cause error, residue parseResidue) {
|
||||
if store.Transient(cause) {
|
||||
// WARN, а не ERROR: пройдёт само, разбирать нечего. Строка нужна, чтобы
|
||||
// повтор не выглядел беспричинным.
|
||||
@@ -375,9 +425,10 @@ func (s *Service) fail(ctx context.Context, deliveryID string, cause error, unco
|
||||
// строка здесь оборвала бы цепочку наследования, то есть изменила бы
|
||||
// результат пересборки журнала.
|
||||
out := store.ParseOutcome{
|
||||
Status: store.ParseFailed,
|
||||
Layer: keepLayer,
|
||||
Uncovered: uncovered,
|
||||
Status: store.ParseFailed,
|
||||
Layer: keepLayer,
|
||||
Uncovered: residue.uncovered,
|
||||
SkippedEntities: residue.skipped,
|
||||
}
|
||||
if err := s.finish(ctx, deliveryID, out); err != nil {
|
||||
s.log.ErrorContext(ctx, "delivery parse status not recorded", "error", err, "delivery_id", deliveryID)
|
||||
|
||||
@@ -386,3 +386,131 @@ func TestFoldНепонятоеСодержимоеВыводитИзОчере
|
||||
t.Errorf("parse_status = %q, ожидался %q", status, store.ParseFailed)
|
||||
}
|
||||
}
|
||||
|
||||
// Пропущенная сущность обязана быть видна в БАЗЕ, а не только в логе. Ретеншен
|
||||
// сырого архива решает «что потеряется, если тело удалить», по учётной записи,
|
||||
// и до этого счётчика получал ответ «терять нечего» ровно там, где потеряна
|
||||
// тренировка с маршрутом: сущность в витрину не попала, список непокрытых
|
||||
// секций пуст, статус `parsed`.
|
||||
func TestFoldПропускиСущностейВидныВУчёте(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, arch, st := newFold(t)
|
||||
ctx := context.Background()
|
||||
|
||||
deliver(t, arch, st, "d1", "Minutes", "a1", fixture(t, "handmade_entities.json"))
|
||||
stats, err := f.Fold(ctx, "d1")
|
||||
if err != nil {
|
||||
t.Fatalf("свёртка: %v", err)
|
||||
}
|
||||
skipped := stats.SkippedNoID + stats.SkippedEntityNoTime + stats.SkippedEntityMalformed
|
||||
if skipped == 0 {
|
||||
t.Fatal("фикстура перестала давать пропуски — тест проверяет не то")
|
||||
}
|
||||
|
||||
d, err := st.LastDelivery(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение доставки: %v", err)
|
||||
}
|
||||
if d.SkippedEntities == nil {
|
||||
t.Fatal("счётчик пропусков пуст: доставка выглядит как «не измерялась»")
|
||||
}
|
||||
if *d.SkippedEntities != int64(skipped) {
|
||||
t.Errorf("в базе %d пропусков, разбор дал %d", *d.SkippedEntities, skipped)
|
||||
}
|
||||
}
|
||||
|
||||
// Число замещает прежнее значение целиком, включая замещение нулём: доставка,
|
||||
// пропуски которой исчезли вместе с поумневшим разбором, не должна остаться
|
||||
// помеченной навсегда.
|
||||
func TestFoldПересвёрткаБезПропусковОбнуляетСчётчик(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, arch, st := newFold(t)
|
||||
ctx := context.Background()
|
||||
|
||||
deliver(t, arch, st, "d1", "Minutes", "a1", fixture(t, "handmade_entities.json"))
|
||||
if _, err := f.Fold(ctx, "d1"); err != nil {
|
||||
t.Fatalf("свёртка: %v", err)
|
||||
}
|
||||
before, err := st.LastDelivery(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение доставки: %v", err)
|
||||
}
|
||||
if before.SkippedEntities == nil || *before.SkippedEntities == 0 {
|
||||
t.Fatal("фикстура перестала давать пропуски — тест проверяет не то")
|
||||
}
|
||||
|
||||
// Тело подменяется на такое же, но без кривых элементов: ровно то, что
|
||||
// произойдёт при пересвёртке поумневшим разбором.
|
||||
deliver(t, arch, st, "d2", "Minutes", "a1", fixture(t, "workout_indoor.json"))
|
||||
if _, err := f.Fold(ctx, "d2"); err != nil {
|
||||
t.Fatalf("повторная свёртка: %v", err)
|
||||
}
|
||||
after, err := st.LastDelivery(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение доставки: %v", err)
|
||||
}
|
||||
if after.SkippedEntities == nil || *after.SkippedEntities != 0 {
|
||||
t.Errorf("счётчик %v, ожидался ноль", after.SkippedEntities)
|
||||
}
|
||||
}
|
||||
|
||||
// Доставка, свёрнутая разбором, который пропусков не считал, обязана быть
|
||||
// отличима от доставки с нулём: подстановка нуля объявила бы её проверенной, и
|
||||
// ретеншен получил бы ложное «терять нечего» с видом измерения.
|
||||
func TestFoldДоНачалаУчётаПропускиНеИзмерены(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, arch, st := newFold(t)
|
||||
ctx := context.Background()
|
||||
|
||||
deliver(t, arch, st, "d1", "Minutes", "a1", fixture(t, "minute.json"))
|
||||
d, err := st.LastDelivery(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение доставки: %v", err)
|
||||
}
|
||||
if d.SkippedEntities != nil {
|
||||
t.Errorf("несвёрнутая доставка отдаёт %d вместо «не измерялось»", *d.SkippedEntities)
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ, при котором разбор не досчитал, обязан оставить счётчик НЕТРОНУТЫМ.
|
||||
// Ноль здесь означал бы «проверено, терять нечего» — то самое ложное измерение,
|
||||
// ради отказа от которого колонка заведена без умолчания. Тело при этом может
|
||||
// нести сотни тренировок, ни одна из которых не сохранена.
|
||||
func TestFoldОтказРазбораНеПодделываетСчётчикПропусков(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, arch, st := newFold(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Сперва успешная свёртка: счётчик измерен и ненулевой.
|
||||
deliver(t, arch, st, "d1", "Minutes", "a1", fixture(t, "handmade_entities.json"))
|
||||
if _, err := f.Fold(ctx, "d1"); err != nil {
|
||||
t.Fatalf("свёртка: %v", err)
|
||||
}
|
||||
before, err := st.LastDelivery(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение доставки: %v", err)
|
||||
}
|
||||
if before.SkippedEntities == nil || *before.SkippedEntities == 0 {
|
||||
t.Fatal("фикстура перестала давать пропуски — тест проверяет не то")
|
||||
}
|
||||
|
||||
// Теперь тело, которое разбор не понимает: секция есть, но конверт оборван.
|
||||
deliver(t, arch, st, "d2", "Minutes", "a1", []byte(`{"data":{"workouts":[`))
|
||||
if _, err := f.Fold(ctx, "d2"); err == nil {
|
||||
t.Fatal("разбор оборванного тела не отказал")
|
||||
}
|
||||
after, err := st.LastDelivery(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение доставки: %v", err)
|
||||
}
|
||||
if after.ID != "d2" {
|
||||
t.Fatalf("прочитана доставка %q, ожидалась d2", after.ID)
|
||||
}
|
||||
if after.SkippedEntities != nil {
|
||||
t.Errorf("отказ разбора записал %d пропусков как измерение", *after.SkippedEntities)
|
||||
}
|
||||
}
|
||||
|
||||
+95
-15
@@ -19,18 +19,67 @@ const maxEntityID = 128
|
||||
// (находка 16). Метрики и тренировки идут первым, `timeLayout`.
|
||||
const rfc3339Layout = time.RFC3339
|
||||
|
||||
// softString — строка заголовка, которая переживает значение не того типа.
|
||||
//
|
||||
// Значение не того ТИПА стоит одного поля, а не сущности. Правило уже записано
|
||||
// рядом для длительности («нечисловое значение — это пропуск ОДНОГО поля, а не
|
||||
// сломанная сущность»); без него `name`, приехавшее числом, уносит тренировку
|
||||
// вместе с маршрутом — а доставка при этом числится разобранной, и ретеншен
|
||||
// получает ответ «терять нечего» ровно там, где потеряно 95% содержимого.
|
||||
//
|
||||
// Через json.Unmarshaler, а не через разбор ошибки постфактум. Соблазн есть:
|
||||
// encoding/json при несовпадении типа «skips that field and completes the
|
||||
// unmarshaling as best it can» и возвращает *UnmarshalTypeError, то есть
|
||||
// трёхстрочный errors.As выглядел бы равноценным. Он неравноценен — та же
|
||||
// документация оговаривает, что дозаполнение полей ПОСЛЕ проблемного не
|
||||
// гарантировано. Разбор, построенный на этом, перестал бы быть функцией тела:
|
||||
// одна и та же тренировка давала бы разный заголовок в зависимости от порядка
|
||||
// ключей на проводе, а он у HAE нестабилен.
|
||||
//
|
||||
// Различение счётчиков сохраняется само: элемент, который сам не объект, даёт
|
||||
// ошибку ВЕРХНЕГО уровня и по-прежнему уходит в «не разобралось как объект», а
|
||||
// не в «нет id».
|
||||
// Признак `present` отличает «ключа не было» от «ключ был, но строки из него не
|
||||
// вышло». Различие нужно ровно одному полю — метке начала, — и там оно
|
||||
// существенно: см. фолбэк `start → date` ниже.
|
||||
//
|
||||
// Именно «ключ был», а не «значение не той формы»: `null` тоже даёт пустую
|
||||
// строку, и без этого различения `{"date":"…","start":null}` уводил бы
|
||||
// тренировку на момент времени из другого поля — молча и без счётчика.
|
||||
type softString struct {
|
||||
value string
|
||||
// present — ключ присутствовал в объекте. UnmarshalJSON зовётся только на
|
||||
// присутствующий ключ, поэтому признак взводится безусловно.
|
||||
present bool
|
||||
}
|
||||
|
||||
func (s *softString) UnmarshalJSON(raw []byte) error {
|
||||
// Приёмник задаётся ЦЕЛИКОМ, а не дописывается. JSON допускает повтор
|
||||
// ключа, и encoding/json зовёт UnmarshalJSON на каждое вхождение с
|
||||
// семантикой «побеждает последнее» — так работает соседний Duration и весь
|
||||
// разбор метрик. Накопленный признак сделал бы разбор функцией не тела, а
|
||||
// истории вызовов: `{"start":123,"start":"2025-06-05 …"}` терял бы
|
||||
// тренировку с маршрутом при валидной последней метке.
|
||||
*s = softString{present: true}
|
||||
|
||||
var v string
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
// Значение не строка — поле считается непрочитанным. Ошибку глушим
|
||||
// сознательно: это и есть мягкость, ради которой тип заведён.
|
||||
return nil
|
||||
}
|
||||
s.value = v
|
||||
return nil
|
||||
}
|
||||
|
||||
// entityHead — поля сущности, нужные разбору. Всё остальное остаётся в Raw и
|
||||
// хранится дословно.
|
||||
//
|
||||
// Длительность читается сырым сообщением, а не числом: нечисловое значение —
|
||||
// это пропуск ОДНОГО поля, а не сломанная сущность, и типизированное поле
|
||||
// уводило бы всю тренировку в счётчик «не разобралась как объект».
|
||||
type entityHead struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Date string `json:"date"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
ID softString `json:"id"`
|
||||
Name softString `json:"name"`
|
||||
Date softString `json:"date"`
|
||||
Start softString `json:"start"`
|
||||
End softString `json:"end"`
|
||||
|
||||
Duration json.RawMessage `json:"duration"`
|
||||
}
|
||||
@@ -47,17 +96,41 @@ func decodeEntities(raws []json.RawMessage, kind string, res *Result) []Entity {
|
||||
|
||||
out := make([]Entity, 0, len(raws))
|
||||
for _, raw := range raws {
|
||||
// Род элемента проверяется ДО разбора, потому что `json.Unmarshal`
|
||||
// «null» в структуру ошибкой не считает (для JSON null это no-op) — и
|
||||
// элемент-`null` уходил бы в счётчик «нет id», то есть сменившаяся
|
||||
// форма СЕКЦИИ диагностировалась бы как сменившаяся форма
|
||||
// ИДЕНТИФИКАТОРА. Два счётчика заведены ровно ради этого различия.
|
||||
if !isJSONObject(raw) {
|
||||
res.SkippedEntityMalformed++
|
||||
continue
|
||||
}
|
||||
var head entityHead
|
||||
if err := json.Unmarshal(raw, &head); err != nil {
|
||||
res.SkippedEntityMalformed++
|
||||
continue
|
||||
}
|
||||
if head.ID == "" || len(head.ID) > maxEntityID {
|
||||
// Идентификатор исключение из мягкости: без строкового `id` сущность не
|
||||
// адресуема, а приведение чужого нестрокового значения к строке было бы
|
||||
// выдумыванием идентичности за источник. Нестроковый `id` мягкое чтение
|
||||
// уже превратило в пустую строку — исход тот же, что у отсутствующего.
|
||||
id := head.ID.value
|
||||
if id == "" || len(id) > maxEntityID {
|
||||
res.SkippedNoID++
|
||||
continue
|
||||
}
|
||||
|
||||
start, ok := parseEntityTime(firstNonEmpty(head.Start, head.Date))
|
||||
// Фолбэк `start → date` существует для сущностей, у которых ключа
|
||||
// `start` НЕТ ВОВСЕ. Если ключ пришёл, но строки из него не вышло
|
||||
// (число, объект, `null`), фолбэк не срабатывает: композиция двух
|
||||
// правил подставила бы метку ДРУГОГО момента времени — неотличимую от
|
||||
// настоящей и ничем не считаемую. Такой `start` считается неразбираемой
|
||||
// меткой.
|
||||
if head.Start.present && head.Start.value == "" {
|
||||
res.SkippedEntityNoTime++
|
||||
continue
|
||||
}
|
||||
start, ok := parseEntityTime(firstNonEmpty(head.Start.value, head.Date.value))
|
||||
if !ok {
|
||||
res.SkippedEntityNoTime++
|
||||
continue
|
||||
@@ -68,17 +141,17 @@ func decodeEntities(raws []json.RawMessage, kind string, res *Result) []Entity {
|
||||
// координату, — а сущность адресуется своим `id`, и схлопывать нечего.
|
||||
// Истина при этом остаётся в Raw дословно.
|
||||
end := start
|
||||
if head.End != "" {
|
||||
if e, ok := parseEntityTime(head.End); ok {
|
||||
if head.End.value != "" {
|
||||
if e, ok := parseEntityTime(head.End.value); ok {
|
||||
end = e
|
||||
}
|
||||
}
|
||||
|
||||
_, offset := start.Zone()
|
||||
e := Entity{
|
||||
ID: head.ID,
|
||||
ID: id,
|
||||
Kind: kind,
|
||||
Name: head.Name,
|
||||
Name: head.Name.value,
|
||||
Start: start.UTC(),
|
||||
End: end.UTC(),
|
||||
OffsetSeconds: offset,
|
||||
@@ -136,6 +209,13 @@ func parseDuration(raw json.RawMessage) *float64 {
|
||||
return &v
|
||||
}
|
||||
|
||||
// isJSONObject говорит, является ли значение объектом JSON, по первому байту
|
||||
// литерала — так же, как это делает сканер encoding/json.
|
||||
func isJSONObject(raw json.RawMessage) bool {
|
||||
lit := bytes.TrimSpace(raw)
|
||||
return len(lit) > 0 && lit[0] == '{'
|
||||
}
|
||||
|
||||
func firstNonEmpty(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
|
||||
+109
-7
@@ -149,14 +149,14 @@ func TestParseКраевыеСлучаиСущностей(t *testing.T) {
|
||||
byID[w.ID] = w
|
||||
}
|
||||
|
||||
// Пустой id, отсутствующий id и id длиннее предела — один счётчик на три
|
||||
// случая: исход у них общий.
|
||||
if res.SkippedNoID != 3 {
|
||||
t.Errorf("пропущено по идентификатору %d, ожидалось 3", res.SkippedNoID)
|
||||
// Пустой id, отсутствующий id, id длиннее предела и id не строкой — один
|
||||
// счётчик на четыре случая: исход у них общий, сущность не адресуема.
|
||||
if res.SkippedNoID != 4 {
|
||||
t.Errorf("пропущено по идентификатору %d, ожидалось 4", res.SkippedNoID)
|
||||
}
|
||||
// Метка не разбирается и метки нет вовсе.
|
||||
if res.SkippedEntityNoTime != 2 {
|
||||
t.Errorf("пропущено по метке %d, ожидалось 2", res.SkippedEntityNoTime)
|
||||
// Метка не разбирается, метки нет вовсе и начало приехало не строкой.
|
||||
if res.SkippedEntityNoTime != 3 {
|
||||
t.Errorf("пропущено по метке %d, ожидалось 3", res.SkippedEntityNoTime)
|
||||
}
|
||||
// Элемент, не являющийся объектом.
|
||||
if res.SkippedEntityMalformed != 1 {
|
||||
@@ -176,6 +176,50 @@ func TestParseКраевыеСлучаиСущностей(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// Значение не того ТИПА стоит одного поля, а не сущности: иначе `name`,
|
||||
// приехавшее числом, уносит тренировку вместе с маршрутом, а доставка при
|
||||
// этом числится разобранной.
|
||||
t.Run("имя числом не уносит тренировку", func(t *testing.T) {
|
||||
w, ok := byID["00000000-0000-4000-8000-00000000000c"]
|
||||
if !ok {
|
||||
t.Fatal("тренировка с именем-числом потерялась целиком")
|
||||
}
|
||||
if w.Name != "" {
|
||||
t.Errorf("имя %q, ожидалось пустое", w.Name)
|
||||
}
|
||||
if !strings.Contains(string(w.Raw), `"lat"`) {
|
||||
t.Error("маршрут не сохранился дословно")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("конец числом не уносит тренировку", func(t *testing.T) {
|
||||
w, ok := byID["00000000-0000-4000-8000-00000000000d"]
|
||||
if !ok {
|
||||
t.Fatal("тренировка с концом-числом потерялась целиком")
|
||||
}
|
||||
if !w.End.Equal(w.Start) {
|
||||
t.Errorf("конец %v, ожидался равным началу %v", w.End, w.Start)
|
||||
}
|
||||
})
|
||||
|
||||
// Фолбэк `start → date` существует для сущностей, у которых `start` не
|
||||
// прислан ВОВСЕ. Непонятое значение `start` фолбэка не получает: подстановка
|
||||
// другого поля дала бы метку другого момента времени, неотличимую от
|
||||
// настоящей и ничем не считаемую.
|
||||
t.Run("начало числом не подменяется полем date", func(t *testing.T) {
|
||||
if _, ok := byID["00000000-0000-4000-8000-00000000000e"]; ok {
|
||||
t.Error("нестроковое начало молча заменено меткой из date")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("идентификатор числом пропускает сущность", func(t *testing.T) {
|
||||
for id := range byID {
|
||||
if id == "42" {
|
||||
t.Error("нестроковый идентификатор приведён к строке — идентичность выдумана за источник")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("нечисловая длительность не становится нулём", func(t *testing.T) {
|
||||
w := byID["00000000-0000-4000-8000-000000000004"]
|
||||
if w.Duration != nil {
|
||||
@@ -316,3 +360,61 @@ func TestParseНепокрытыеСекцииССобственнымиID(t *te
|
||||
t.Errorf("непокрытые %v", res.Uncovered)
|
||||
}
|
||||
}
|
||||
|
||||
// Элемент, который сам не объект, обязан идти в СВОЙ счётчик: сменившаяся форма
|
||||
// секции и сменившаяся форма идентификатора лечатся по-разному. `null` при этом
|
||||
// самый коварный — `json.Unmarshal` считает его законным no-op и не ошибается.
|
||||
func TestParseЭлементНеОбъектИдётВСвойСчётчик(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res, err := hae.Parse([]byte(`{"data":{"workouts":[null,"строка",42,[1,2]]}}`), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if res.SkippedEntityMalformed != 4 {
|
||||
t.Errorf("не разобралось как объект %d, ожидалось 4", res.SkippedEntityMalformed)
|
||||
}
|
||||
if res.SkippedNoID != 0 {
|
||||
t.Errorf("пропущено по идентификатору %d, ожидалось 0: форма секции — не форма id",
|
||||
res.SkippedNoID)
|
||||
}
|
||||
}
|
||||
|
||||
// Повтор ключа JSON допускает, и весь разбор проекта пользуется семантикой
|
||||
// «побеждает последнее». Мягкое чтение обязано ей следовать: признак, копящийся
|
||||
// между вызовами, сделал бы заголовок функцией истории вызовов, а не тела.
|
||||
func TestParseПовторКлючаМеткиРешаетсяПоследнимЗначением(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := `{"data":{"workouts":[{"id":"w1","start":123,"start":"2025-06-05 07:00:00 +0300"}]}}`
|
||||
res, err := hae.Parse([]byte(body), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if len(res.Workouts) != 1 {
|
||||
t.Fatalf("тренировок %d, ожидалась 1: валидная метка стоит последней", len(res.Workouts))
|
||||
}
|
||||
if res.Workouts[0].Start.IsZero() {
|
||||
t.Error("метка не разобралась")
|
||||
}
|
||||
}
|
||||
|
||||
// `"start": null` — это ключ, который пришёл. Фолбэк на `date` для него не
|
||||
// срабатывает: подстановка дала бы метку ДРУГОГО момента времени, неотличимую
|
||||
// от настоящей и ничем не считаемую.
|
||||
func TestParseПустойStartНеПодменяетсяПолемDate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := `{"data":{"workouts":[{"id":"w1","date":"2025-06-01 00:00:00 +0300",` +
|
||||
`"start":null,"end":"2025-06-05 07:10:00 +0300"}]}}`
|
||||
res, err := hae.Parse([]byte(body), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if len(res.Workouts) != 0 {
|
||||
t.Errorf("тренировка получила метку %v из чужого поля", res.Workouts[0].Start)
|
||||
}
|
||||
if res.SkippedEntityNoTime != 1 {
|
||||
t.Errorf("пропущено по метке %d, ожидалось 1", res.SkippedEntityNoTime)
|
||||
}
|
||||
}
|
||||
|
||||
+77
-6
@@ -220,7 +220,7 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
res = Result{}
|
||||
err = fmt.Errorf("%w: паника разбора: %v", ErrMalformed, r)
|
||||
err = fmt.Errorf("%w: паника разбора: %s", ErrMalformed, clip(fmt.Sprint(r)))
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -422,7 +422,7 @@ func decodeEnvelope(body []byte) (envelope, error) {
|
||||
var env envelope
|
||||
|
||||
fail := func(e error) (envelope, error) {
|
||||
return envelope{}, fmt.Errorf("%w: %v", ErrMalformed, e) //nolint:errorlint // причина уходит в лог, наружу не раскрывается
|
||||
return envelope{}, fmt.Errorf("%w: %s", ErrMalformed, ClipCause(e))
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(bytes.NewReader(body))
|
||||
@@ -430,6 +430,7 @@ func decodeEnvelope(body []byte) (envelope, error) {
|
||||
// Верхний уровень тела: интересует только data. Прочие ключи конверта в
|
||||
// список не идут — иначе в одном списке смешались бы имена секций и мусор
|
||||
// конверта, а форму `{"data": …}` проверяет приём.
|
||||
at := dec.InputOffset()
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
@@ -441,7 +442,7 @@ func decodeEnvelope(body []byte) (envelope, error) {
|
||||
return envelope{}, nil
|
||||
}
|
||||
if d, ok := tok.(json.Delim); !ok || d != '{' {
|
||||
return fail(fmt.Errorf("ожидался объект, встречено %v", tok))
|
||||
return fail(fmt.Errorf("ожидался объект, встречено %s", tokenDesc(tok, at)))
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
for dec.More() {
|
||||
@@ -486,13 +487,14 @@ type envelope struct {
|
||||
// decodeData разбирает объект data, дописывая в конверт покрытые секции и
|
||||
// имена непокрытых.
|
||||
func decodeData(dec *json.Decoder, seen map[string]struct{}, env *envelope) error {
|
||||
at := dec.InputOffset()
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// data не объект — прежнее поведение: ошибка ровно там, где была.
|
||||
if d, ok := tok.(json.Delim); !ok || d != '{' {
|
||||
return fmt.Errorf("data: ожидался объект, встречено %v", tok)
|
||||
return fmt.Errorf("data: ожидался объект, встречено %s", tokenDesc(tok, at))
|
||||
}
|
||||
|
||||
for dec.More() {
|
||||
@@ -544,17 +546,85 @@ func decodeSection(dec *json.Decoder) ([]json.RawMessage, error) {
|
||||
// memberName читает имя члена объекта. Token() отдаёт имя уже после разбора
|
||||
// escape-последовательностей, поэтому границы считаются по декодированному.
|
||||
func memberName(dec *json.Decoder) (string, error) {
|
||||
at := dec.InputOffset()
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name, ok := tok.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("ожидалось имя члена, встречено %v", tok)
|
||||
return "", fmt.Errorf("ожидалось имя члена, встречено %s", tokenDesc(tok, at))
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// maxCauseLen — предел длины чужой причины в тексте нашей ошибки.
|
||||
//
|
||||
// Сообщения самого разбора значений не несут (см. tokenDesc), но ошибка может
|
||||
// прийти и из encoding/json, а его UnmarshalTypeError кладёт в текст ЛИТЕРАЛ
|
||||
// значения: тело из миллиона цифр давало текст ошибки в мегабайт, и он уезжал
|
||||
// атрибутом `error` выше DEBUG. Предел держится здесь, на границе, а не у
|
||||
// логирующего: обрезка живёт в другом месте и о новой ошибке разбора не узнает.
|
||||
const maxCauseLen = 200
|
||||
|
||||
// ClipCause переводит чужую ошибку в ограниченную по длине строку.
|
||||
//
|
||||
// Экспортировано ради приёма: он проверяет форму конверта тем же
|
||||
// encoding/json и обязан держать тот же предел — иначе инвариант обходится
|
||||
// через соседний пакет.
|
||||
func ClipCause(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return clip(err.Error())
|
||||
}
|
||||
|
||||
func clip(s string) string {
|
||||
if len(s) <= maxCauseLen {
|
||||
return s
|
||||
}
|
||||
// По границе рун: обрезка посреди многобайтовой руны даёт мусор в логе.
|
||||
cut := maxCauseLen
|
||||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||
cut--
|
||||
}
|
||||
return s[:cut] + "…"
|
||||
}
|
||||
|
||||
// tokenDesc описывает встреченный токен БЕЗ его значения: род и смещение начала
|
||||
// во входе.
|
||||
//
|
||||
// Значение из тела в сообщение не попадает никогда. Инвариант «тела запросов
|
||||
// только на DEBUG и с обрезкой» обходится одним `%v`: строка в 8 МиБ на месте
|
||||
// ожидаемого объекта давала текст ошибки в 8 МиБ, и он уезжал атрибутом `error`
|
||||
// на уровень WARN — то есть содержимое доставки оказывалось в логе целиком.
|
||||
// Предел держит само сообщение, а не обрезка на стороне логирующего: обрезка
|
||||
// живёт в другом месте и о новой ошибке разбора не узнает.
|
||||
//
|
||||
// Род называется словарём JSON, а не именем типа языка: `json.Delim` не говорит
|
||||
// ничего о том, какая скобка встретилась. Сам делимитер печатается значением —
|
||||
// он из фиксированного набора и содержимого не раскрывает.
|
||||
//
|
||||
// Смещение берётся ДО чтения токена: InputOffset() отдаёт позицию конца
|
||||
// последнего возвращённого токена, и снятое после оно указывало бы на конец
|
||||
// виновного значения — то есть на восемь мегабайт дальше начала проблемы.
|
||||
func tokenDesc(tok json.Token, at int64) string {
|
||||
kind := "?"
|
||||
switch v := tok.(type) {
|
||||
case json.Delim:
|
||||
kind = fmt.Sprintf("%q", string(v))
|
||||
case string:
|
||||
kind = "string"
|
||||
case json.Number, float64:
|
||||
kind = "number"
|
||||
case bool:
|
||||
kind = "bool"
|
||||
case nil:
|
||||
kind = "null"
|
||||
}
|
||||
return fmt.Sprintf("%s на смещении %d", kind, at)
|
||||
}
|
||||
|
||||
// swallow проглатывает значение целиком, ничего не удерживая.
|
||||
func swallow(dec *json.Decoder) error {
|
||||
var skip json.RawMessage
|
||||
@@ -562,12 +632,13 @@ func swallow(dec *json.Decoder) error {
|
||||
}
|
||||
|
||||
func expectDelim(dec *json.Decoder, want json.Delim) error {
|
||||
at := dec.InputOffset()
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d, ok := tok.(json.Delim); !ok || d != want {
|
||||
return fmt.Errorf("ожидалось %q, встречено %v", want, tok)
|
||||
return fmt.Errorf("ожидалось %q, встречено %s", want, tokenDesc(tok, at))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -702,3 +702,87 @@ func TestParseНепокрытыеСекцииГраницыИДетермини
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Инвариант «тела запросов только на DEBUG и с обрезкой» обходился одним `%v`:
|
||||
// строка в 8 МиБ на месте ожидаемого объекта давала текст ошибки в 8 МиБ, и он
|
||||
// уезжал атрибутом `error` на уровень WARN — то есть содержимое доставки
|
||||
// оказывалось в логе целиком и без обрезки.
|
||||
//
|
||||
// Предел держит само сообщение, а не обрезка на стороне логирующего: обрезка
|
||||
// живёт в другом месте и о новой ошибке разбора не узнает.
|
||||
func TestParseОшибкаНеНесётЗначенийИзТела(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const secret = "СЕКРЕТНОЕ-ЗНАЧЕНИЕ-ИЗ-ТЕЛА"
|
||||
cases := map[string]string{
|
||||
"строка вместо data": `{"data":"` + secret + strings.Repeat("A", 1<<20) + `"}`,
|
||||
"строка вместо тела": `"` + secret + strings.Repeat("A", 1<<20) + `"`,
|
||||
"число вместо имени секции": `{"data":{"metrics":[]},"` + secret + `":1}`,
|
||||
"число вместо содержимого": `{"data":{"metrics":` + strings.Repeat("9", 1<<20) + `}}`,
|
||||
}
|
||||
for name, body := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := hae.Parse([]byte(body), hae.Meta{})
|
||||
if err == nil {
|
||||
t.Skip("вход разобрался — проверять нечего")
|
||||
}
|
||||
msg := err.Error()
|
||||
if len(msg) > 512 {
|
||||
t.Errorf("текст ошибки %d Б: длина зависит от длины значения во входе", len(msg))
|
||||
}
|
||||
if strings.Contains(msg, secret) {
|
||||
t.Errorf("значение из тела доехало до сообщения: %s", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Смещение обязано указывать на место ПЕРЕД виновным токеном, а не за ним.
|
||||
// Декодер сообщает позицию как конец последнего возвращённого токена, поэтому
|
||||
// снятая ПОСЛЕ чтения она отличалась бы от начала проблемы ровно на длину
|
||||
// значения — на восемь мегабайт в том самом случае, ради которого требование и
|
||||
// написано. Проверяется само свойство: смещение не зависит от длины значения.
|
||||
func TestParseОшибкаНазываетТипТокенаИНачало(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
short := `{"data":"` + strings.Repeat("A", 16) + `"}`
|
||||
long := `{"data":"` + strings.Repeat("A", 1<<20) + `"}`
|
||||
|
||||
msgs := make([]string, 0, 2)
|
||||
for _, body := range []string{short, long} {
|
||||
_, err := hae.Parse([]byte(body), hae.Meta{})
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка")
|
||||
}
|
||||
msgs = append(msgs, err.Error())
|
||||
}
|
||||
|
||||
if !strings.Contains(msgs[0], "string") {
|
||||
t.Errorf("тип токена не назван словарём JSON: %s", msgs[0])
|
||||
}
|
||||
// `{"data"` — семь байт: разбор виновного значения начинается здесь.
|
||||
if !strings.Contains(msgs[0], "смещении 7") {
|
||||
t.Errorf("смещение не указывает на место перед токеном: %s", msgs[0])
|
||||
}
|
||||
if msgs[0] != msgs[1] {
|
||||
t.Errorf("смещение поехало вместе с длиной значения:\n %s\n %s", msgs[0], msgs[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Ошибка может прийти не только от нашего разбора, но и из encoding/json, а его
|
||||
// UnmarshalTypeError кладёт в текст ЛИТЕРАЛ значения: тело из миллиона цифр
|
||||
// давало текст ошибки в мегабайт. Предел держится на границе пакета.
|
||||
func TestParseЧужаяПричинаОбрезается(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := `{"data":{"metrics":` + strings.Repeat("9", 1<<20) + `}}`
|
||||
_, err := hae.Parse([]byte(body), hae.Meta{})
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка")
|
||||
}
|
||||
if len(err.Error()) > 512 {
|
||||
t.Errorf("текст ошибки %d Б: литерал из тела доехал до сообщения", len(err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -74,6 +74,30 @@
|
||||
"end": "2025-06-05 16:01:00 +0300"
|
||||
},
|
||||
"не объект вовсе",
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-00000000000c",
|
||||
"name": 5,
|
||||
"start": "2025-06-05 18:00:00 +0300",
|
||||
"end": "2025-06-05 18:10:00 +0300",
|
||||
"route": [{"lat": 1}]
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-00000000000d",
|
||||
"name": "Конец числом",
|
||||
"start": "2025-06-05 19:00:00 +0300",
|
||||
"end": 0
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"name": "Идентификатор числом",
|
||||
"start": "2025-06-05 20:00:00 +0300"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-00000000000e",
|
||||
"name": "Начало числом при живом date",
|
||||
"start": 1749100000,
|
||||
"date": "2025-06-05 22:00:00 +0300"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000009",
|
||||
"name": "Незнакомое поле и дословные литералы",
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/archive"
|
||||
"git.vakhrushev.me/av/healthlog/internal/hae"
|
||||
"git.vakhrushev.me/av/healthlog/internal/ident"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
@@ -158,7 +159,17 @@ func (s *Service) Accept(ctx context.Context, body []byte, meta Meta) (Result, e
|
||||
if err != nil {
|
||||
// Тело уже на диске — данные не потеряны, но учёта нет. Такое тело
|
||||
// подберёт пересборка (`healthlog reindex`), заведя запись заново.
|
||||
s.log.ErrorContext(ctx, "delivery failed", "error", err, "delivery_id", res.DeliveryID, "raw_path", rawPath)
|
||||
//
|
||||
// Занятость базы называется отдельно. Уровень от этого не меняется:
|
||||
// тело осиротело в любом случае, и вернуть его в журнал может только
|
||||
// пересборка. Но лечится занятость не тем, чем сбой диска или испорченная
|
||||
// база, — это конкуренция за запись, и она будет повторяться. Признак
|
||||
// снимается с доменной ошибки, а не с предиката «обстоятельства вообще»:
|
||||
// тот включает ещё и отмену снаружи, а здесь она невозможна по
|
||||
// построению — учёт ведётся на контексте, переживающем обрыв соединения.
|
||||
s.log.ErrorContext(ctx, "delivery failed", "error", err,
|
||||
"delivery_id", res.DeliveryID, "raw_path", rawPath,
|
||||
"db_busy", errors.Is(err, store.ErrBusy))
|
||||
return Result{}, fmt.Errorf("record delivery: %w", err)
|
||||
}
|
||||
|
||||
@@ -227,7 +238,11 @@ func checkEnvelope(body []byte) error {
|
||||
|
||||
var env envelope
|
||||
if err := json.Unmarshal(body, &env); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrMalformed, err) //nolint:errorlint // причину наружу не раскрываем, она уходит в лог
|
||||
// Причина обрезается тем же пределом, что и в разборе: UnmarshalTypeError
|
||||
// кладёт в текст ЛИТЕРАЛ значения, и тело из миллиона цифр давало
|
||||
// мегабайт содержимого доставки в логе. Инвариант «тела только на DEBUG
|
||||
// и с обрезкой» относится и к DEBUG.
|
||||
return fmt.Errorf("%w: %s", ErrMalformed, hae.ClipCause(err))
|
||||
}
|
||||
if len(env.Data) == 0 {
|
||||
return fmt.Errorf("%w: нет объекта data", ErrMalformed)
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package ingest_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/archive"
|
||||
@@ -261,3 +264,70 @@ func newService(t *testing.T) (*ingest.Service, *archive.Archive, *store.Store)
|
||||
st, arch := newDeps(t, t.TempDir())
|
||||
return ingest.New(arch, st, nil, slog.New(slog.DiscardHandler)), arch, st
|
||||
}
|
||||
|
||||
// Отказ учёта после того, как тело легло в архив, обязан называть класс
|
||||
// причины: занятость базы — конкуренция за запись, которая будет повторяться, и
|
||||
// лечится она не тем же, чем сбой диска. Уровень при этом остаётся ERROR: тело
|
||||
// осиротело в любом случае, и вернуть его в журнал может только пересборка.
|
||||
func TestAcceptОтказУчётаНазываетКлассПричины(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, arch := newDeps(t, dir)
|
||||
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewJSONHandler(&buf, nil))
|
||||
svc := ingest.New(arch, st, nil, log)
|
||||
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("закрытие базы: %v", err)
|
||||
}
|
||||
if _, err := svc.Accept(context.Background(), []byte(`{"data":{"metrics":[]}}`), ingest.Meta{}); err == nil {
|
||||
t.Fatal("приём не заметил, что доставка не учтена")
|
||||
}
|
||||
|
||||
var rec map[string]any
|
||||
for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
|
||||
var v map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &v); err != nil {
|
||||
t.Fatalf("строка лога не JSON: %v", err)
|
||||
}
|
||||
if v["msg"] == "delivery failed" {
|
||||
rec = v
|
||||
}
|
||||
}
|
||||
if rec == nil {
|
||||
t.Fatal("отказ учёта не залогирован")
|
||||
}
|
||||
if rec["level"] != "ERROR" {
|
||||
t.Errorf("уровень %v, ожидался ERROR: тело осиротело", rec["level"])
|
||||
}
|
||||
busy, ok := rec["db_busy"].(bool)
|
||||
if !ok {
|
||||
t.Fatalf("класс причины не назван: %v", rec)
|
||||
}
|
||||
// Закрытая база — не занятость: признак обязан различать, а не стоять всегда.
|
||||
if busy {
|
||||
t.Error("закрытая база названа занятой — признак не различает причины")
|
||||
}
|
||||
}
|
||||
|
||||
// Инвариант «тела запросов только на DEBUG и с обрезкой» относится и к DEBUG:
|
||||
// проверка формы конверта идёт через encoding/json, чей UnmarshalTypeError
|
||||
// кладёт в текст литерал значения.
|
||||
func TestAcceptОтказФормыНеНесётТелаВЛог(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
// Ни архив, ни база не нужны: тело неверной формы отвергается проверкой
|
||||
// конверта до всякой записи.
|
||||
svc := ingest.New(nil, nil, nil, log)
|
||||
|
||||
body := []byte(`{"data":` + strings.Repeat("9", 1<<20) + `}`)
|
||||
if _, err := svc.Accept(context.Background(), body, ingest.Meta{}); err == nil {
|
||||
t.Fatal("тело неверной формы принято")
|
||||
}
|
||||
if buf.Len() > 4096 {
|
||||
t.Errorf("строка лога %d Б: содержимое тела уехало в лог", buf.Len())
|
||||
}
|
||||
if strings.Contains(buf.String(), strings.Repeat("9", 256)) {
|
||||
t.Error("литерал из тела виден в логе")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,14 @@ func TestReplayЖивогоАрхива(t *testing.T) {
|
||||
if first.Records == 0 {
|
||||
t.Error("записей в витрине нет — секция stateOfMind не разбирается")
|
||||
}
|
||||
t.Logf("тренировок %d, записей %d", first.Workouts, first.Records)
|
||||
// Удержанные версии сущностей печатаются рядом с их числом. Число — это
|
||||
// «сколько лежит», а удержания — «сколько правило слияния не пустило», и
|
||||
// второе отпечатком не проверяется по построению: живой приём и пересборка
|
||||
// пользуются одним правилом и одинаково сойдутся на одинаково удержанной
|
||||
// версии. Печатается, а не утверждается: удержание — событие для разбора,
|
||||
// а не отказ сходимости.
|
||||
t.Logf("тренировок %d, записей %d, удержано версий сущностей %d",
|
||||
first.Workouts, first.Records, first.EntitiesHeld)
|
||||
|
||||
// Повторное проигрывание того же журнала даёт то же состояние: свёртка
|
||||
// детерминирована, и пересборка даёт то же, что живой приём.
|
||||
|
||||
@@ -32,6 +32,17 @@ type Outcome struct {
|
||||
// Incomparable — столкновений с несравнимыми наборами полей. На живом потоке
|
||||
// их не было ни разу, и на этом стоит отказ от объединения полей.
|
||||
Incomparable int
|
||||
// EntitiesHeld — версий сущностей, удержанных правилом «не теряем
|
||||
// содержания». Без него правило слияния сущностей проверить нечем:
|
||||
// сходимость отпечатка его не проверяет ПО ПОСТРОЕНИЮ — живой приём и
|
||||
// пересборка пользуются одним правилом и одинаково сойдутся на одинаково
|
||||
// удержанной версии. То есть слишком строгое правило (замораживающее
|
||||
// тренировку на старой версии) выглядело бы идеальной сходимостью.
|
||||
EntitiesHeld int
|
||||
// EntitiesDiverging — версии одного ключа, приехавшие в одном теле с разным
|
||||
// содержанием. Событие другого рода, чем удержание, и считается отдельно:
|
||||
// смешанное число не отвечало бы ни на один из двух вопросов.
|
||||
EntitiesDiverging int
|
||||
}
|
||||
|
||||
// Add накапливает исход одной доставки в общий.
|
||||
@@ -43,6 +54,8 @@ func (o *Outcome) Add(other Outcome) {
|
||||
o.FailedOther += other.FailedOther
|
||||
o.Partial += other.Partial
|
||||
o.Incomparable += other.Incomparable
|
||||
o.EntitiesHeld += other.EntitiesHeld
|
||||
o.EntitiesDiverging += other.EntitiesDiverging
|
||||
}
|
||||
|
||||
// classify раскладывает ошибку свёртки по классам исхода.
|
||||
@@ -52,7 +65,7 @@ func (o *Outcome) Add(other Outcome) {
|
||||
// проверяется она перебором классов, без базы и без архива.
|
||||
//
|
||||
// Неэкспортируемая намеренно: её результат содержит поля `Partial` и
|
||||
// `Incomparable`, которые дописывает только Play, — вторая публичная дверь
|
||||
// `Incomparable`, `EntitiesHeld` и `EntitiesDiverging`, которые дописывает только Play, — вторая публичная дверь
|
||||
// молча занижала бы именно тот счётчик, по которому принимается решение о
|
||||
// судьбе тела в архиве.
|
||||
func classify(err error) Outcome {
|
||||
@@ -105,6 +118,8 @@ func (p Player) Play(ctx context.Context, deliveryID string) (Outcome, error) {
|
||||
out.Partial++
|
||||
}
|
||||
out.Incomparable += st.Incomparable
|
||||
out.EntitiesHeld += st.EntitiesHeld
|
||||
out.EntitiesDiverging += st.EntitiesDiverging
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -203,6 +203,8 @@ func Run(ctx context.Context, o Options) (Report, error) {
|
||||
"failed_other", rep.FailedOther,
|
||||
"partial", rep.Partial,
|
||||
"incomparable", rep.Incomparable,
|
||||
"entities_held", rep.EntitiesHeld,
|
||||
"entities_diverging", rep.EntitiesDiverging,
|
||||
"buckets", rep.Buckets,
|
||||
"workouts", rep.Workouts,
|
||||
"records", rep.Records)
|
||||
@@ -333,8 +335,11 @@ func stopOr(rep Report, err error) (Report, error) {
|
||||
// prepare оставляет от учётной записи ФАКТЫ ЖУРНАЛА и сбрасывает производные от
|
||||
// разбора поля.
|
||||
//
|
||||
// `parse_status`, `points`, `derived_layer` и `uncovered_sections` — результат
|
||||
// ПРЕДЫДУЩЕЙ свёртки, а не то, что приехало вместе с доставкой. Перенести их
|
||||
// `parse_status`, `points`, `derived_layer`, `uncovered_sections` и
|
||||
// `skipped_entities` — результат ПРЕДЫДУЩЕЙ свёртки, а не то, что приехало
|
||||
// вместе с доставкой. У последнего пустота означает «не измерялось», так что
|
||||
// перенос выдал бы измерение прежнего разбора за измерение текущего — а по нему
|
||||
// решают, можно ли удалить тело. Перенести их
|
||||
// значило бы сделать пересобранную витрину функцией прошлого прогона: доставка,
|
||||
// чей повторный разбор отказал (штатный исход, когда слой не выводится),
|
||||
// сохранила бы слой прежнего разбора — свёртка не затирает его намеренно, — и
|
||||
|
||||
@@ -625,3 +625,55 @@ func TestИмяТелаОбязаноБытьКаноническим(t *testing
|
||||
t.Errorf("повторов %d: настоящее тело вытеснено подложенным", rep.Duplicates)
|
||||
}
|
||||
}
|
||||
|
||||
// Число пропущенных сущностей — производное от разбора поле, и пересборка его
|
||||
// не переносит. Пустота у него значит «не измерялось», а перенесённое число
|
||||
// выдавало бы измерение ПРЕЖНЕГО разбора за измерение текущего — притом что по
|
||||
// нему принимается необратимое решение об удалении тела.
|
||||
func TestЧислоПропусковНеПереноситсяВПересобраннуюБазу(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
arch := openArchive(t, filepath.Join(dir, "raw"))
|
||||
src := openStore(t, filepath.Join(dir, "live.db"))
|
||||
ctx := context.Background()
|
||||
|
||||
items := journal(t, "minute.json")
|
||||
live(t, arch, src, items)
|
||||
|
||||
// Проставляем счётчик в исходной базе, как если бы его измерил прежний
|
||||
// разбор, и убираем тело: пересборке будет нечего пересчитывать.
|
||||
n := int64(7)
|
||||
for _, it := range items {
|
||||
err := src.FinishParse(ctx, it.id, store.ParseOutcome{
|
||||
Status: store.ParseDone,
|
||||
SkippedEntities: &n,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("простановка счётчика: %v", err)
|
||||
}
|
||||
}
|
||||
// Тело убираем: доставка становится записью без тела, пересборка её не
|
||||
// сворачивает — и производные поля обязаны начаться пустыми, а не приехать
|
||||
// из журнала.
|
||||
raw := filepath.Join(dir, "raw")
|
||||
if err := os.RemoveAll(raw); err != nil {
|
||||
t.Fatalf("удаление тел: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(raw, 0o755); err != nil {
|
||||
t.Fatalf("пересоздание каталога архива: %v", err)
|
||||
}
|
||||
|
||||
rep, dst := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
|
||||
if rep.Orphans == 0 {
|
||||
t.Fatal("доставка не стала записью без тела — тест проверяет не то")
|
||||
}
|
||||
|
||||
got, err := dst.LastDelivery(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение доставки: %v", err)
|
||||
}
|
||||
if got.SkippedEntities != nil {
|
||||
t.Errorf("пересборка перенесла счётчик прежнего разбора: %d", *got.SkippedEntities)
|
||||
}
|
||||
}
|
||||
|
||||
+80
-54
@@ -84,9 +84,23 @@ type MergeStats struct {
|
||||
// EntitiesHeld — приехавшие версии, отклонённые как теряющие содержание
|
||||
// сохранённой (включая несравнимые наборы). Это и есть плата за отказ
|
||||
// объединять поля: событие считается, а не предотвращается молча.
|
||||
//
|
||||
// На него опирается ЕДИНСТВЕННЫЙ контроль того, что правило покрытия не
|
||||
// стало слишком строгим: сходимость отпечатка этого не проверяет по
|
||||
// построению — живой приём и пересборка пользуются одним правилом и
|
||||
// одинаково сойдутся на одинаково удержанной версии. Поэтому счётчик
|
||||
// обязан считать ровно удержания и ничего сверх.
|
||||
EntitiesHeld int
|
||||
// HeldAt — координаты первых таких сущностей, для записи в лог.
|
||||
HeldAt []EntityRef
|
||||
// EntitiesDiverging — версии одного ключа, приехавшие в ОДНОМ теле с разным
|
||||
// содержанием. Событие другого рода: победитель ложится в витрину целиком,
|
||||
// терять нечего, лечится оно не тем же. Считается отдельно от удержаний,
|
||||
// иначе одно число отвечало бы на два вопроса — и число удержаний, по
|
||||
// которому судят о строгости правила, стало бы неотличимо от шума.
|
||||
EntitiesDiverging int
|
||||
// DivergingAt — координаты первых таких сущностей.
|
||||
DivergingAt []EntityRef
|
||||
}
|
||||
|
||||
// Collision — координаты объекта, где столкновение разрешилось перезаписью
|
||||
@@ -140,10 +154,22 @@ func (s *Store) Merge(ctx context.Context, in Incoming, from DeliveryRef) (Merge
|
||||
groups := groupByHour(in.Points)
|
||||
keys := sortedKeys(groups)
|
||||
|
||||
// Хеш и каноническая форма сущности считаются ОДИН раз на доставку, до
|
||||
// входа в транзакцию: канонизация материализует значение целиком, а
|
||||
// транзакция повторяется до пяти раз при занятости базы — внутри неё пик
|
||||
// кучи умножился бы на число попыток.
|
||||
// Каноническая форма сущности и её хеш считаются ОДИН раз на версию и здесь
|
||||
// — до входа в транзакцию. Транзакция открыта `immediate`, то есть блокирует
|
||||
// запись, и повторяется до пяти раз при занятости базы: канонизация внутри
|
||||
// неё умножала бы и пик кучи, и время удержания блокировки. Измерено: тело
|
||||
// 40 МиБ даёт 768 МиБ пика, 63 МиБ удерживают блокировку 5.019 с при
|
||||
// busy_timeout 5000, после чего конкурентный CreateDelivery исчерпывает
|
||||
// повторы.
|
||||
//
|
||||
// Пределов остаётся два, и оба названы вслух. Первый: разбор СОХРАНЁННОЙ
|
||||
// версии остаётся внутри транзакции — её содержимое читается оттуда же и
|
||||
// только когда хеш разошёлся; удержание блокировки пропорционально её
|
||||
// размеру. Второй: форма и множества ключей всех версий доставки
|
||||
// УДЕРЖИВАЮТСЯ в памяти до конца транзакции, то есть расход пропорционален
|
||||
// размеру доставки, а не самой большой её сущности. Про процессор здесь
|
||||
// стало лучше, про память — хуже, и закрыть оба может лишь предел на размер
|
||||
// сущности вместе с потоковым расчётом.
|
||||
workouts, err := prepareEntities(in.Workouts, from)
|
||||
if err != nil {
|
||||
return MergeStats{}, err
|
||||
@@ -152,18 +178,25 @@ func (s *Store) Merge(ctx context.Context, in Incoming, from DeliveryRef) (Merge
|
||||
if err != nil {
|
||||
return MergeStats{}, err
|
||||
}
|
||||
workouts, workoutsHeld, workoutsHeldAt := dedupeEntities(workouts)
|
||||
records, recordsHeld, recordsHeldAt := dedupeEntities(records)
|
||||
workouts, workoutsDiverging, workoutsDivergingAt, err := dedupeEntities(ctx, workouts)
|
||||
if err != nil {
|
||||
return MergeStats{}, err
|
||||
}
|
||||
records, recordsDiverging, recordsDivergingAt, err := dedupeEntities(ctx, records)
|
||||
if err != nil {
|
||||
return MergeStats{}, err
|
||||
}
|
||||
|
||||
var stats MergeStats
|
||||
err = s.inTx(ctx, func(tx *sql.Tx) error {
|
||||
// Счётчики обнуляются на каждой попытке: повтор транзакции начинает
|
||||
// слияние заново, и накопленное от прошлой попытки посчиталось бы дважды.
|
||||
stats = MergeStats{
|
||||
Workouts: len(in.Workouts),
|
||||
Records: len(in.Records),
|
||||
EntitiesHeld: workoutsHeld + recordsHeld,
|
||||
HeldAt: clipRefs(append(append([]EntityRef{}, workoutsHeldAt...), recordsHeldAt...)),
|
||||
Workouts: len(in.Workouts),
|
||||
Records: len(in.Records),
|
||||
EntitiesDiverging: workoutsDiverging + recordsDiverging,
|
||||
DivergingAt: clipRefs(append(append([]EntityRef{},
|
||||
workoutsDivergingAt...), recordsDivergingAt...)),
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
@@ -300,7 +333,10 @@ func mergeBucket(ctx context.Context, tx *sql.Tx, key bucketKey, group *pointGro
|
||||
return res, err
|
||||
}
|
||||
|
||||
merged, overwrites, incomparable := mergePoints(stored.Points, group.points)
|
||||
merged, overwrites, incomparable, err := mergePoints(ctx, stored.Points, group.points)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.overwrites = overwrites
|
||||
res.incomparable = incomparable
|
||||
// Считаем сохранённые точки, а не присланные: точные повторы внутри
|
||||
@@ -356,7 +392,7 @@ func mergeBucket(ctx context.Context, tx *sql.Tx, key bucketKey, group *pointGro
|
||||
// не заговорит.
|
||||
//
|
||||
// Точки из объекта не удаляются никогда.
|
||||
func mergePoints(stored, incoming []Point) (merged []Point, overwrites, incomparable int) {
|
||||
func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point, overwrites, incomparable int, err error) {
|
||||
type coord struct {
|
||||
start int64
|
||||
end int64
|
||||
@@ -404,7 +440,10 @@ func mergePoints(stored, incoming []Point) (merged []Point, overwrites, incompar
|
||||
out := make([]Point, 0, len(order))
|
||||
for _, c := range order {
|
||||
cands := byCoord[c]
|
||||
winner, unrelated := resolve(cands)
|
||||
winner, unrelated, err := resolve(ctx, cands)
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
// Перезаписей столько, сколько точек уступило: при двух кандидатах
|
||||
// одна, при трёх две. Так счёт остаётся сравнимым с прежним, где
|
||||
// столкновение считалось на каждую приехавшую точку.
|
||||
@@ -425,7 +464,7 @@ func mergePoints(stored, incoming []Point) (merged []Point, overwrites, incompar
|
||||
}
|
||||
return out[i].End.Before(out[j].End)
|
||||
})
|
||||
return out, overwrites, incomparable
|
||||
return out, overwrites, incomparable, nil
|
||||
}
|
||||
|
||||
// candidate — точка вместе с тем, что о ней нужно знать при выборе
|
||||
@@ -444,14 +483,11 @@ func newCandidate(p Point) candidate {
|
||||
|
||||
// resolve выбирает победителя среди кандидатов одной координаты.
|
||||
//
|
||||
// Победитель — функция МНОЖЕСТВА кандидатов, а не порядка их поступления.
|
||||
// Сперва отбрасываются те, кого превосходит по полноте кто-то другой
|
||||
// (полнота — частичный порядок, поэтому «непревзойдённые» определены
|
||||
// однозначно), затем среди оставшихся берётся минимум по каноническому
|
||||
// порядку — он тотальный, поэтому минимум единственен. Обе операции зависят
|
||||
// только от состава множества, поэтому пересборка журнала даёт то же
|
||||
// состояние, что живой приём, а повторная свёртка той же доставки не меняет
|
||||
// ничего.
|
||||
// Механизм общий с выбором версии сущности — pickBest: отбрасываем
|
||||
// превзойдённых по частичному порядку, среди оставшихся берём минимум по
|
||||
// тотальному. Отношения разные (полнота у точек, покрытие у сущностей), а
|
||||
// рассуждение одно, и второй его экземпляр однажды уже разошёлся со стандартом
|
||||
// нетранзитивностью.
|
||||
//
|
||||
// Победителем остаётся одна из пришедших точек ДОСЛОВНО: правило выбирает, а
|
||||
// не конструирует. Каноническая форма существует только в момент сравнения, и
|
||||
@@ -461,46 +497,36 @@ func newCandidate(p Point) candidate {
|
||||
// несравнимыми наборами содержательных полей. На живом потоке этого не
|
||||
// случилось ни разу (0 из 2 897 столкновений), поэтому объединение полей не
|
||||
// реализовано: вместо него счётчик, который скажет, если событие наступит.
|
||||
func resolve(cands []candidate) (Point, bool) {
|
||||
if len(cands) == 1 {
|
||||
return cands[0].pt, false
|
||||
}
|
||||
|
||||
maximal := make([]candidate, 0, len(cands))
|
||||
for i, a := range cands {
|
||||
dominated := false
|
||||
for j, b := range cands {
|
||||
if i == j {
|
||||
continue
|
||||
}
|
||||
if b.fields.Relate(a.fields) == canon.FullnessSuperset {
|
||||
dominated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !dominated {
|
||||
maximal = append(maximal, a)
|
||||
}
|
||||
}
|
||||
|
||||
best := maximal[0]
|
||||
for _, c := range maximal[1:] {
|
||||
if bytes.Compare(c.key, best.key) < 0 {
|
||||
best = c
|
||||
}
|
||||
func resolve(ctx context.Context, cands []candidate) (Point, bool, error) {
|
||||
winner, maximal, err := pickBest(ctx, cands, pointDominates, pointLess)
|
||||
if err != nil {
|
||||
return Point{}, false, err
|
||||
}
|
||||
|
||||
// Несравнимость — не «осталось больше одного»: точки с одинаковыми
|
||||
// наборами полей и разными значениями тоже остаются обе, и это рядовой
|
||||
// тай-брейк. Считается только то, ради чего отложено объединение полей:
|
||||
// у каждой из двух есть содержательный ключ, которого нет у другой.
|
||||
return best.pt, hasIncomparablePair(maximal)
|
||||
return cands[winner].pt, hasIncomparablePair(cands, maximal), nil
|
||||
}
|
||||
|
||||
func hasIncomparablePair(cands []candidate) bool {
|
||||
for i := range cands {
|
||||
for j := i + 1; j < len(cands); j++ {
|
||||
if cands[i].fields.Relate(cands[j].fields) == canon.FullnessIncomparable {
|
||||
// pointDominates — строгое превосходство по полноте. Relate возвращает
|
||||
// FullnessSuperset только когда a несёт всё, что b, и сверх того, поэтому
|
||||
// отношение уже строгое.
|
||||
func pointDominates(a, b candidate) bool {
|
||||
return a.fields.Relate(b.fields) == canon.FullnessSuperset
|
||||
}
|
||||
|
||||
// pointLess — тотальный порядок по канонической форме. Минимум единствен:
|
||||
// кандидаты с равной формой схлопываются ещё при сборе множества.
|
||||
func pointLess(a, b candidate) bool {
|
||||
return bytes.Compare(a.key, b.key) < 0
|
||||
}
|
||||
|
||||
func hasIncomparablePair(cands []candidate, maximal []int) bool {
|
||||
for i := range maximal {
|
||||
for j := i + 1; j < len(maximal); j++ {
|
||||
if cands[maximal[i]].fields.Relate(cands[maximal[j]].fields) == canon.FullnessIncomparable {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,20 @@ type Delivery struct {
|
||||
// имён. Ответ на вопрос «что останется потерянным, если тело удалить»:
|
||||
// ретеншен обязан спрашивать его прежде, чем срезать тело.
|
||||
UncoveredSections string
|
||||
// SkippedEntities — сколько сущностей с собственным `id` разбор пропустил.
|
||||
// Второй половина ответа на тот же вопрос: сущность, которую разбор не
|
||||
// понял, в витрину не попала, а список непокрытых секций про неё молчит.
|
||||
//
|
||||
// Отсутствие значения означает «не измерялось» и НЕ равно нулю: так
|
||||
// выглядят доставки, свёрнутые разбором, который пропусков не считал, и те,
|
||||
// чей разбор не досчитал. Читатель, принимающий по счётчику необратимое
|
||||
// решение, обязан трактовать отсутствие как «не удалять».
|
||||
//
|
||||
// Указателем, а не sql.NullInt64: поле уедет в JSON `/stats` и в MCP, а
|
||||
// NullInt64 сериализуется формой драйвера (`{"Int64":0,"Valid":false}`) —
|
||||
// первый, кто про это забудет, опубликует её наружу, и она станет
|
||||
// контрактом. Указатель даёт `null` бесплатно и означает ровно то же.
|
||||
SkippedEntities *int64
|
||||
}
|
||||
|
||||
// CreateDelivery записывает факт приёма пакета.
|
||||
@@ -92,21 +106,26 @@ func (s *Store) LastDelivery(ctx context.Context) (Delivery, error) {
|
||||
const q = `
|
||||
SELECT id, received_at, automation_name, automation_id, aggregation,
|
||||
period, session_id, bytes, sha256, raw_path, parse_status,
|
||||
points, headers, uncovered_sections
|
||||
points, headers, uncovered_sections, skipped_entities
|
||||
FROM delivery ORDER BY received_at DESC, id DESC LIMIT 1`
|
||||
|
||||
var d Delivery
|
||||
var receivedAt string
|
||||
// sql.NullInt64 живёт ровно на границе сканирования и наружу не выходит.
|
||||
var skipped sql.NullInt64
|
||||
err := s.db.QueryRowxContext(ctx, q).Scan(
|
||||
&d.ID, &receivedAt, &d.AutomationName, &d.AutomationID, &d.Aggregation,
|
||||
&d.Period, &d.SessionID, &d.Bytes, &d.SHA256, &d.RawPath,
|
||||
&d.ParseStatus, &d.Points, &d.Headers, &d.UncoveredSections)
|
||||
&d.ParseStatus, &d.Points, &d.Headers, &d.UncoveredSections, &skipped)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Delivery{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Delivery{}, fmt.Errorf("select last delivery: %w", err)
|
||||
}
|
||||
if skipped.Valid {
|
||||
d.SkippedEntities = &skipped.Int64
|
||||
}
|
||||
|
||||
d.ReceivedAt, err = ParseTime(receivedAt)
|
||||
if err != nil {
|
||||
@@ -120,11 +139,19 @@ func (s *Store) LastDelivery(ctx context.Context) (Delivery, error) {
|
||||
//
|
||||
// Отдаются только **факты журнала**: то, что пришло вместе с доставкой.
|
||||
// Производные от разбора поля (`parse_status`, `points`, `derived_layer`,
|
||||
// `uncovered_sections`) сюда не попадают намеренно — перенос их в пересобранную
|
||||
// базу сделал бы витрину функцией предыдущего прогона. Особенно `derived_layer`:
|
||||
// доставка, чей повторный разбор отказал, отдала бы в наследование слой
|
||||
// прежнего разбора, и следующая доставка той же автоматизации унаследовала бы
|
||||
// его молча.
|
||||
// `uncovered_sections`, `skipped_entities`) сюда не попадают намеренно —
|
||||
// перенос их в пересобранную базу сделал бы витрину функцией предыдущего
|
||||
// прогона. Особенно `derived_layer`: доставка, чей повторный разбор отказал,
|
||||
// отдала бы в наследование слой прежнего разбора, и следующая доставка той же
|
||||
// автоматизации унаследовала бы его молча. У `skipped_entities` цена та же и
|
||||
// хуже: пустота у него значит «не измерялось», и перенесённое число выдавало бы
|
||||
// измерение прежнего разбора за измерение текущего — а по нему принимается
|
||||
// необратимое решение об удалении тела.
|
||||
//
|
||||
// Перечень пополняется ТЕМ ЖЕ изменением, которое заводит новое поле: он
|
||||
// единственное место, где сказано, чему нельзя пережить пересборку, и следующий
|
||||
// автор решает по нему. Поле, не внесённое сюда, однажды перенесут «для полноты
|
||||
// учёта».
|
||||
func (s *Store) ListDeliveries(ctx context.Context) ([]Delivery, error) {
|
||||
const q = `
|
||||
SELECT id, received_at, automation_name, automation_id, aggregation,
|
||||
@@ -255,6 +282,17 @@ type ParseOutcome struct {
|
||||
// у слоя пустота — отсутствие знания, у списка — знание об отсутствии.
|
||||
// Пересвёртка доставки, чья секция стала покрытой, обязана список очистить.
|
||||
Uncovered []string
|
||||
// SkippedEntities — сколько сущностей с собственным `id` разбор пропустил.
|
||||
// Пишется, когда разбор ДОСЧИТАЛ, включая ноль: доставка, пропуски которой
|
||||
// исчезли вместе с поумневшим разбором, не должна остаться помеченной
|
||||
// навсегда.
|
||||
//
|
||||
// nil означает «не измерялось» и колонку НЕ ТРОГАЕТ — та же идиома, что у
|
||||
// пустого Layer. Без неё отказ на чтении тела или паника разбора писали бы
|
||||
// ноль, то есть «проверено, терять нечего», в доставку, содержимое которой
|
||||
// никто не смотрел: ровно та подстановка, ради отказа от которой колонка
|
||||
// заведена без DEFAULT.
|
||||
SkippedEntities *int64
|
||||
}
|
||||
|
||||
func (s *Store) FinishParse(ctx context.Context, id string, out ParseOutcome) error {
|
||||
@@ -262,7 +300,8 @@ func (s *Store) FinishParse(ctx context.Context, id string, out ParseOutcome) er
|
||||
UPDATE delivery
|
||||
SET parse_status = ?, points = ?,
|
||||
derived_layer = CASE WHEN ? = '' THEN derived_layer ELSE ? END,
|
||||
uncovered_sections = ?
|
||||
uncovered_sections = ?,
|
||||
skipped_entities = CASE WHEN ? THEN ? ELSE skipped_entities END
|
||||
WHERE id = ?`
|
||||
|
||||
// Ровно одно представление пустоты — `[]`: nil-срез Go сериализуется как
|
||||
@@ -276,7 +315,17 @@ func (s *Store) FinishParse(ctx context.Context, id string, out ParseOutcome) er
|
||||
return fmt.Errorf("encode uncovered sections: %w", err)
|
||||
}
|
||||
|
||||
res, err := s.db.ExecContext(ctx, q, out.Status, out.Points, out.Layer, out.Layer, string(encoded), id)
|
||||
// Отсутствие числа не пишется нулём: ноль означает «измерено, пропусков не
|
||||
// было», а нам нужно «не измерялось». Колонка остаётся какой была — та же
|
||||
// форма, что у слоя строкой выше.
|
||||
measured := out.SkippedEntities != nil
|
||||
var skipped int64
|
||||
if measured {
|
||||
skipped = *out.SkippedEntities
|
||||
}
|
||||
|
||||
res, err := s.db.ExecContext(ctx, q, out.Status, out.Points, out.Layer, out.Layer,
|
||||
string(encoded), measured, skipped, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update parse status: %w", err)
|
||||
}
|
||||
|
||||
+259
-93
@@ -106,21 +106,23 @@ func clipRefs(refs []EntityRef) []EntityRef {
|
||||
// entityVersion — версия сущности вместе с тем, что нужно знать при выборе
|
||||
// победителя.
|
||||
//
|
||||
// Разбор и канонизация ОТЛОЖЕНЫ: они нужны только когда хеш разошёлся с
|
||||
// сохранённым, то есть на одной доставке из сорока четырёх. Считать их сразу
|
||||
// значило бы разворачивать маршрут (95% веса тренировки, до мегабайта) в дерево
|
||||
// значений на каждой копии — ровно та форма, от которой разбор тела отказался
|
||||
// замером (197 МиБ кучи против 54 МиБ на теле 42 МиБ). Хеш при этом считается
|
||||
// сразу и один раз на доставку: он и есть быстрый путь.
|
||||
// Всё считается СРАЗУ и один раз на версию, до входа в транзакцию. Ленивость
|
||||
// здесь была мнимой: хеш всё равно требует полной канонической формы, то есть
|
||||
// самая дорогая работа платилась на каждой копии и так, а отложенный разбор
|
||||
// считал ту же форму ВТОРОЙ раз — и делал это внутри транзакции, которая
|
||||
// открыта `immediate` и повторяется до пяти раз при занятости базы.
|
||||
//
|
||||
// Баланс назван честно: на пути разошедшегося хеша (одна доставка из сорока
|
||||
// четырёх) стало на одну полную канонизацию меньше; на пути совпавшего хеша
|
||||
// добавился мелкий разбор в map[string]json.RawMessage — проход по телу без
|
||||
// разворачивания значений. Внутри транзакции для приехавших версий не остаётся
|
||||
// ничего.
|
||||
type entityVersion struct {
|
||||
raw json.RawMessage
|
||||
hash string
|
||||
from DeliveryRef
|
||||
|
||||
// key и fields заполняются лениво, методом analyze().
|
||||
raw json.RawMessage
|
||||
hash string
|
||||
from DeliveryRef
|
||||
key []byte
|
||||
fields canon.Fields
|
||||
parsed bool
|
||||
|
||||
// head — заголовок, который пишется колонками. У сохранённой версии он не
|
||||
// нужен: она либо побеждает и остаётся как есть, либо замещается целиком.
|
||||
@@ -128,21 +130,56 @@ type entityVersion struct {
|
||||
}
|
||||
|
||||
func newEntityVersion(e IncomingEntity, from DeliveryRef) (entityVersion, error) {
|
||||
h, err := canon.Hash(e.Raw)
|
||||
v, err := analyzeVersion(e.Raw, from)
|
||||
if err != nil {
|
||||
return entityVersion{}, fmt.Errorf("хеш сущности: %w", err)
|
||||
return entityVersion{}, err
|
||||
}
|
||||
return entityVersion{raw: e.Raw, hash: h, from: from, head: e}, nil
|
||||
v.head = e
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// analyze разбирает версию, если этого ещё не делали.
|
||||
func (v *entityVersion) analyze() {
|
||||
if v.parsed {
|
||||
return
|
||||
// newStoredVersion собирает версию, прочитанную из витрины.
|
||||
//
|
||||
// Каноническая форма здесь НЕ считается, и это существенно: разбор сохранённой
|
||||
// версии — единственная работа, которая осталась внутри транзакции, открытой
|
||||
// `immediate`. Замер на тренировке в 168 КБ: полная канонизация с хешем — 4.5 мс
|
||||
// и 2.3 МБ на 38 тысячах аллокаций, множества ключей — 1.3 мс и 174 КБ на
|
||||
// тридцати. Хеш сохранённой уже лежит колонкой, а форма нужна ровно в одной
|
||||
// ветке тай-брейка (равные позиции журнала — та же доставка, свёрнутая
|
||||
// повторно) и считается там лениво.
|
||||
func newStoredVersion(raw json.RawMessage, hash string, from DeliveryRef) entityVersion {
|
||||
return entityVersion{
|
||||
raw: raw,
|
||||
hash: hash,
|
||||
from: from,
|
||||
fields: canon.Analyze(raw),
|
||||
}
|
||||
v.key = canon.SortKey(v.raw)
|
||||
v.fields = canon.Analyze(v.raw)
|
||||
v.parsed = true
|
||||
}
|
||||
|
||||
func analyzeVersion(raw json.RawMessage, from DeliveryRef) (entityVersion, error) {
|
||||
form, hash, err := canon.FormAndHash(raw)
|
||||
if err != nil {
|
||||
return entityVersion{}, fmt.Errorf("канонизация сущности: %w", err)
|
||||
}
|
||||
return entityVersion{
|
||||
raw: raw,
|
||||
hash: hash,
|
||||
from: from,
|
||||
key: form,
|
||||
fields: canon.Analyze(raw),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sortKey отдаёт каноническую форму версии, считая её при необходимости.
|
||||
//
|
||||
// Ленивость здесь одна на весь файл и нужна ровно сохранённой версии: у неё
|
||||
// форма требуется только в тай-брейке равных позиций журнала, а стоит она
|
||||
// втрое дороже разбора и платится под блокировкой записи.
|
||||
func (v *entityVersion) sortKey() []byte {
|
||||
if v.key == nil {
|
||||
v.key = canon.SortKey(v.raw)
|
||||
}
|
||||
return v.key
|
||||
}
|
||||
|
||||
// pickEntity выбирает между сохранённой и приехавшей версией.
|
||||
@@ -177,35 +214,23 @@ func pickEntity(stored, incoming *entityVersion) (takeIncoming, lost bool) {
|
||||
// Объединение полей отвергнуто там же и по той же причине, что для
|
||||
// точек, — на живом потоке событие не наступало ни разу, — а из двух
|
||||
// версий остаётся сохранённая: правило называется «не теряет
|
||||
// содержания», и приехавшая его теряет. Исход при этом остаётся
|
||||
// функцией журнала: доставки проигрываются в его порядке.
|
||||
// содержания», и приехавшая его теряет.
|
||||
//
|
||||
// ЗДЕСЬ И ТОЛЬКО ЗДЕСЬ исход зависит от порядка свёртки, а не от
|
||||
// журнала: в витрине лежит победитель прошлых слияний, а не все
|
||||
// кандидаты истории, и «сохранённая выигрывает» означает разный итог
|
||||
// при разном порядке. Порядок свёртки журналу не равен — доставка,
|
||||
// получившая ErrBusy, остаётся `pending` и сворачивается следующим
|
||||
// проходом, — так что живой приём и пересборка на несравнимых версиях
|
||||
// законно расходятся. Это единственная точка, где витрина не является
|
||||
// функцией множества доставок; она названа вслух в architecture.md, и
|
||||
// счётчик удержаний ниже — единственное, что о ней сообщает.
|
||||
return false, true
|
||||
default:
|
||||
return laterInJournal(stored, incoming), false
|
||||
}
|
||||
}
|
||||
|
||||
// pickWithinDelivery выбирает между двумя версиями одного ключа ВНУТРИ одной
|
||||
// доставки. Второй возврат — различается ли их содержание вообще.
|
||||
//
|
||||
// Отдельно от pickEntity, и не ради симметрии: «сохранённой» версии здесь нет,
|
||||
// есть только порядок элементов в JSON-массиве, а он нестабилен. Правило
|
||||
// «остаётся первая встреченная» сделало бы исход функцией порядка на проводе,
|
||||
// поэтому при равном и при несравнимом содержании решает тотальный порядок
|
||||
// канонических форм.
|
||||
func pickWithinDelivery(a, b *entityVersion) (takeB, differs bool) {
|
||||
switch v := compareEntities(a, b); v {
|
||||
case entityIncomingRicher:
|
||||
return true, true
|
||||
case entityStoredRicher:
|
||||
return false, true
|
||||
case entityIncomparable:
|
||||
return laterInJournal(a, b), true
|
||||
default:
|
||||
return laterInJournal(a, b), false
|
||||
}
|
||||
}
|
||||
|
||||
// entityVerdict — как соотносится СОДЕРЖАНИЕ двух версий одной сущности.
|
||||
// Нумерация с единицы: нулевое значение не должно выглядеть как «равны».
|
||||
type entityVerdict int
|
||||
@@ -221,9 +246,6 @@ const (
|
||||
)
|
||||
|
||||
func compareEntities(stored, incoming *entityVersion) entityVerdict {
|
||||
stored.analyze()
|
||||
incoming.analyze()
|
||||
|
||||
storedCovers := stored.fields.Covers(incoming.fields)
|
||||
incomingCovers := incoming.fields.Covers(stored.fields)
|
||||
|
||||
@@ -250,58 +272,135 @@ func laterInJournal(stored, incoming *entityVersion) bool {
|
||||
if incoming.from.before(stored.from) {
|
||||
return false
|
||||
}
|
||||
return bytes.Compare(incoming.key, stored.key) < 0
|
||||
return bytes.Compare(incoming.sortKey(), stored.sortKey()) < 0
|
||||
}
|
||||
|
||||
// dedupeEntities сворачивает версии одного ключа ВНУТРИ доставки тем же
|
||||
// правилом — до сравнения с сохранённой.
|
||||
// entityDominates говорит, СТРОГО ли a превосходит b по содержанию: покрывает и
|
||||
// не покрывается в ответ.
|
||||
//
|
||||
// Без этого исход зависел бы от того, как написан цикл: карта по ключу дала бы
|
||||
// победу последнему элементу массива мимо правила полноты, а порядок элементов
|
||||
// в JSON-массиве нестабилен.
|
||||
//
|
||||
// Счётчик здесь считает СИММЕТРИЧНО — «в одном теле приехали две версии одного
|
||||
// ключа с разным содержанием», — а не «приехавшая обеднена». Внутри доставки
|
||||
// «сохранённой» версии не существует, есть только порядок элементов массива, и
|
||||
// счётчик, зависящий от него, наблюдал бы событие через раз.
|
||||
func dedupeEntities(versions []entityVersion) ([]entityVersion, int, []EntityRef) {
|
||||
type slot struct {
|
||||
v entityVersion
|
||||
pos int
|
||||
}
|
||||
// Строгость обязательна. Covers — предпорядок, а не строгий порядок: две версии
|
||||
// могут покрывать друг друга взаимно (тот же набор ключей, другие значения), и
|
||||
// отбрасывание «всего, что кем-то покрыто» опустошило бы множество, потеряв обе.
|
||||
func entityDominates(a, b entityVersion) bool {
|
||||
return a.fields.Covers(b.fields) && !b.fields.Covers(a.fields)
|
||||
}
|
||||
|
||||
byKey := make(map[EntityRef]slot, len(versions))
|
||||
// entityLess — тотальный порядок на версиях равного содержания.
|
||||
//
|
||||
// Сперва каноническая форма, потом ИСХОДНЫЕ БАЙТЫ. Второй разряд не украшение:
|
||||
// у сущностей версии с равной формой не схлопываются (в отличие от точек, где
|
||||
// это делает дедупликация по ключу), а у HAE порядок ключей в JSON и запись
|
||||
// числа нестабильны — то есть без него минимум неединствен, и в витрину лёг бы
|
||||
// тот элемент, что стоял в массиве раньше. Порядок элементов на проводе не
|
||||
// имеет права решать, какие байты хранятся.
|
||||
func entityLess(a, b entityVersion) bool {
|
||||
if c := bytes.Compare(a.key, b.key); c != 0 {
|
||||
return c < 0
|
||||
}
|
||||
return bytes.Compare(a.raw, b.raw) < 0
|
||||
}
|
||||
|
||||
// dedupeEntities сворачивает версии одного ключа ВНУТРИ доставки — до сравнения
|
||||
// с сохранённой.
|
||||
//
|
||||
// Победитель здесь — функция МНОЖЕСТВА версий, а не порядка элементов массива:
|
||||
// сперва отбрасываются строго покрытые, среди оставшихся берётся минимум
|
||||
// тотального порядка. Попарная свёртка была неверна ровно так же, как она была
|
||||
// неверна для точек: покрытие — частичный порядок, тай-брейк — тотальный, и
|
||||
// вместе они дают нетранзитивную победу, при которой [A,B,C] и [B,C,A] дают
|
||||
// разных победителей.
|
||||
//
|
||||
// Версии с СОВПАВШЕЙ канонической формой схлопываются ДО выбора победителя, и
|
||||
// это не оптимизация ради красоты: выбор квадратичен по числу кандидатов, а их
|
||||
// число приходит из чужого тела. Точки схлопываются так же и в том же месте
|
||||
// (см. mergePoints). Внутри схлопнутой группы остаются минимальные байты —
|
||||
// тот же второй разряд тотального порядка, что и между группами.
|
||||
//
|
||||
// Второй возврат — счётчик «в одном теле приехали версии одного ключа с РАЗНЫМ
|
||||
// содержанием», симметричный по построению: считаются кандидаты, чья форма
|
||||
// отличается от формы победителя. По форме, а не по байтам: порядок ключей у
|
||||
// HAE нестабилен и дребезг последнего разряда тоже, так что побайтовый счётчик
|
||||
// срабатывал бы на норме потока и стал бы неотличим от шума ровно тогда, когда
|
||||
// понадобился бы.
|
||||
//
|
||||
// Счётчик отдельный от «удержаний», а не общий с ними. Две версии в одном теле —
|
||||
// это НЕ потеря содержания: победитель ложится в витрину целиком, и удерживать
|
||||
// нечего. Смешивать их значило бы отвечать одним числом на два вопроса, которые
|
||||
// лечатся по-разному, — а на число удержаний опирается единственный контроль
|
||||
// того, что правило покрытия не стало слишком строгим.
|
||||
func dedupeEntities(ctx context.Context, versions []entityVersion) ([]entityVersion, int, []EntityRef, error) {
|
||||
byKey := make(map[EntityRef][]entityVersion, len(versions))
|
||||
order := make([]EntityRef, 0, len(versions))
|
||||
held := 0
|
||||
var heldAt []EntityRef
|
||||
|
||||
for _, v := range versions {
|
||||
ref := EntityRef{Kind: v.head.Kind, ID: v.head.ID}
|
||||
prev, seen := byKey[ref]
|
||||
if !seen {
|
||||
byKey[ref] = slot{v: v, pos: len(order)}
|
||||
if _, seen := byKey[ref]; !seen {
|
||||
order = append(order, ref)
|
||||
continue
|
||||
}
|
||||
takeB, differs := pickWithinDelivery(&prev.v, &v)
|
||||
if differs {
|
||||
held++
|
||||
if len(heldAt) < maxEntityRefsReported {
|
||||
heldAt = append(heldAt, ref)
|
||||
}
|
||||
}
|
||||
winner := prev.v
|
||||
if takeB {
|
||||
winner = v
|
||||
}
|
||||
byKey[ref] = slot{v: winner, pos: prev.pos}
|
||||
byKey[ref] = append(byKey[ref], v)
|
||||
}
|
||||
|
||||
out := make([]entityVersion, 0, len(order))
|
||||
diverging := 0
|
||||
var divergingAt []EntityRef
|
||||
|
||||
for _, ref := range order {
|
||||
out = append(out, byKey[ref].v)
|
||||
cands, dropped := collapseEqualForms(byKey[ref])
|
||||
winner, _, err := pickBest(ctx, cands, entityDominates, entityLess)
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
out = append(out, cands[winner])
|
||||
|
||||
// Схлопнутые копии победителя различием не считаются: их форма ему
|
||||
// равна. Считаются все прочие — и оставшиеся кандидаты, и те, что
|
||||
// схлопнулись в них.
|
||||
differing := 0
|
||||
for i := range cands {
|
||||
if i != winner {
|
||||
differing += 1 + dropped[i]
|
||||
}
|
||||
}
|
||||
if differing > 0 {
|
||||
diverging += differing
|
||||
if len(divergingAt) < maxEntityRefsReported {
|
||||
divergingAt = append(divergingAt, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, held, heldAt
|
||||
return out, diverging, divergingAt, nil
|
||||
}
|
||||
|
||||
// collapseEqualForms схлопывает версии с одинаковой канонической формой в одну,
|
||||
// оставляя минимальные исходные байты. Второй возврат — сколько копий сложилось
|
||||
// в каждого оставшегося кандидата (нужно счётчику различий).
|
||||
//
|
||||
// Схлопывание обязательно, а не желательно: без него тело с двадцатью тысячами
|
||||
// повторов одного `id` даёт четыреста миллионов сравнений покрытия, каждое с
|
||||
// обходом массивов. Тело в пределах приёма такое вмещает.
|
||||
func collapseEqualForms(versions []entityVersion) ([]entityVersion, []int) {
|
||||
byForm := make(map[string]int, len(versions))
|
||||
out := make([]entityVersion, 0, len(versions))
|
||||
dropped := make([]int, 0, len(versions))
|
||||
|
||||
for _, v := range versions {
|
||||
form := string(v.key)
|
||||
i, seen := byForm[form]
|
||||
if !seen {
|
||||
byForm[form] = len(out)
|
||||
out = append(out, v)
|
||||
dropped = append(dropped, 0)
|
||||
continue
|
||||
}
|
||||
dropped[i]++
|
||||
if bytes.Compare(v.raw, out[i].raw) < 0 {
|
||||
// Байты решают внутри группы ровно так же, как между группами:
|
||||
// порядок элементов на проводе не имеет права выбирать содержимое.
|
||||
// Заголовок едет вместе с байтами — он от них производен.
|
||||
out[i] = v
|
||||
}
|
||||
}
|
||||
return out, dropped
|
||||
}
|
||||
|
||||
// mergeEntities сливает сущности одной секции с сохранёнными.
|
||||
@@ -320,11 +419,31 @@ func mergeEntities(ctx context.Context, tx *sql.Tx, table string, versions []ent
|
||||
continue
|
||||
}
|
||||
|
||||
// Хеш — детектор изменений: совпал, значит писать нечего, и содержимое
|
||||
// сохранённой сущности читать не приходится вовсе. Тренировка
|
||||
// переприсылается каждой доставкой, пока не доедет маршрут, — на живом
|
||||
// архиве 44 копии дают три различных содержимых.
|
||||
// Хеш — детектор изменений: совпал, значит содержимое то же, и читать
|
||||
// его не приходится вовсе. Тренировка переприсылается каждой доставкой,
|
||||
// пока не доедет маршрут, — на живом архиве 44 копии дают три различных
|
||||
// содержимых.
|
||||
//
|
||||
// Но провенанс при этом обновить НАДО. Сохранённая позиция журнала
|
||||
// участвует в тай-брейке «содержание равно», и если в ней осталась
|
||||
// первая свёрнутая копия вместо победителя журнала, отложенная доставка
|
||||
// вернёт витрину к прежнему содержимому — то есть живая витрина
|
||||
// разойдётся с пересборкой, молча и в содержимом тренировки.
|
||||
//
|
||||
// Предел назван вслух: обновляется провенанс, но НЕ байты. При
|
||||
// совпавшей канонической форме в витрине остаются байты той доставки,
|
||||
// что свернулась первой, — а порядок ключей у HAE нестабилен, значит у
|
||||
// живого приёма и пересборки они могут различаться. Отпечаток этого не
|
||||
// различает (он считает по канонической форме), содержания не теряется
|
||||
// ничего, а переписывать мегабайтный маршрут на каждой из двадцати
|
||||
// шести присылок ради выбора между эквивалентными литералами — цена
|
||||
// несоразмерная.
|
||||
if stored.hash == v.hash {
|
||||
if stored.from.before(v.from) {
|
||||
if err := touchEntityProvenance(ctx, tx, table, v); err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -332,7 +451,7 @@ func mergeEntities(ctx context.Context, tx *sql.Tx, table string, versions []ent
|
||||
if err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
prev := entityVersion{raw: storedRaw, hash: stored.hash, from: stored.from}
|
||||
prev := newStoredVersion(storedRaw, stored.hash, stored.from)
|
||||
|
||||
takeIncoming, lost := pickEntity(&prev, &v)
|
||||
if lost {
|
||||
@@ -352,6 +471,44 @@ func mergeEntities(ctx context.Context, tx *sql.Tx, table string, versions []ent
|
||||
return written, held, heldAt, nil
|
||||
}
|
||||
|
||||
// touchEntityProvenance поднимает провенанс сущности до более поздней доставки
|
||||
// журнала, не трогая содержимое.
|
||||
//
|
||||
// `updated_at` НЕ двигается, и это отдельное решение, а не экономия. Тренировка
|
||||
// приезжает до двадцати шести раз; бамп метки на каждой сделал бы её меткой
|
||||
// касания строки, а не изменения содержимого, и потребитель запроса «что
|
||||
// изменилось с момента X» получил бы двадцать шесть ложных изменений,
|
||||
// неотличимых от настоящего досчёта. Провенанс несёт собственную метку —
|
||||
// времени приёма своей доставки, — и для тай-брейка её достаточно.
|
||||
//
|
||||
// Счётчик записанных сущностей такое обновление тоже не увеличивает: он считает
|
||||
// СОДЕРЖИМОЕ витрины, и сравнимость его с прежними замерами важнее учёта
|
||||
// обновлённой ссылки.
|
||||
func touchEntityProvenance(ctx context.Context, tx *sql.Tx, table string, v entityVersion) error {
|
||||
q := `UPDATE ` + table + ` SET delivery_id = ?, delivery_received_at = ?` + entityWhere(table)
|
||||
|
||||
args := append([]any{v.from.ID, FormatTime(v.from.ReceivedAt)},
|
||||
entityKeyArgs(table, v.head.Kind, v.head.ID)...)
|
||||
|
||||
res, err := tx.ExecContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update %s provenance: %w", table, err)
|
||||
}
|
||||
// Строка гарантированно существует: её заголовок прочитан этой же
|
||||
// транзакцией десятью строками выше. Ноль означал бы, что ключ собран не
|
||||
// теми колонками, — а провенанс в отпечаток витрины не входит, значит
|
||||
// молчаливый промах не поймает ни один оракул сходимости. Соседи по файлу
|
||||
// (FinishParse, MarkSealed) проверяют по той же причине.
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update %s provenance: %w", table, err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("update %s provenance: %w", table, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type storedEntityHead struct {
|
||||
hash string
|
||||
from DeliveryRef
|
||||
@@ -406,11 +563,20 @@ func entityWhere(table string) string {
|
||||
return ` WHERE id = ?`
|
||||
}
|
||||
|
||||
func queryEntity(ctx context.Context, tx *sql.Tx, q, table, kind, id string) *sql.Row {
|
||||
// entityKeyArgs — аргументы к entityWhere. Живут рядом с ним намеренно: число
|
||||
// `?` в тексте и длина этого среза обязаны меняться вместе, а компилятор их
|
||||
// соответствия не видит. Промах даст ошибку SQLite внутри транзакции слияния,
|
||||
// то есть на пути, который повторяется до пяти раз и оканчивается `failed` у
|
||||
// доставки, а не отказом сборки.
|
||||
func entityKeyArgs(table, kind, id string) []any {
|
||||
if table == recordTable {
|
||||
return tx.QueryRowContext(ctx, q, kind, id)
|
||||
return []any{kind, id}
|
||||
}
|
||||
return tx.QueryRowContext(ctx, q, id)
|
||||
return []any{id}
|
||||
}
|
||||
|
||||
func queryEntity(ctx context.Context, tx *sql.Tx, q, table, kind, id string) *sql.Row {
|
||||
return tx.QueryRowContext(ctx, q, entityKeyArgs(table, kind, id)...)
|
||||
}
|
||||
|
||||
func writeEntity(ctx context.Context, tx *sql.Tx, table string, v entityVersion, now time.Time) error {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Внутренний тест, потому что проверяемое наружу не отдаётся: `updated_at` —
|
||||
// колонка, а не поле модели. Обещание «метка означает изменение содержимого, а
|
||||
// не касание строки» держится только этой проверкой, и внешний тест для неё
|
||||
// потребовал бы публичного метода ради теста.
|
||||
func TestMergeПовторНеДвигаетМеткуИзменения(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("открытие базы: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
raw := json.RawMessage(`{"id":"w1","route":[{"lat":1},{"lat":2}],"totalEnergy":{"qty":20}}`)
|
||||
wo := IncomingEntity{
|
||||
ID: "w1",
|
||||
Kind: "workouts",
|
||||
Start: time.Date(2025, 6, 5, 7, 0, 0, 0, time.UTC),
|
||||
End: time.Date(2025, 6, 5, 7, 10, 0, 0, time.UTC),
|
||||
Raw: raw,
|
||||
}
|
||||
merge := func(id string, at time.Time) MergeStats {
|
||||
t.Helper()
|
||||
s, err := st.Merge(ctx, Incoming{Workouts: []IncomingEntity{wo}},
|
||||
DeliveryRef{ID: id, ReceivedAt: at})
|
||||
if err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
updatedAt := func() string {
|
||||
t.Helper()
|
||||
var v string
|
||||
if err := st.db.QueryRowContext(ctx,
|
||||
`SELECT updated_at FROM workout WHERE id = 'w1'`).Scan(&v); err != nil {
|
||||
t.Fatalf("чтение updated_at: %v", err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
base := time.Date(2025, 6, 5, 8, 0, 0, 0, time.UTC)
|
||||
merge("d1", base)
|
||||
before := updatedAt()
|
||||
|
||||
// Тренировка приезжает до 26 раз, пока источник её досчитывает. Провенанс
|
||||
// при этом обязан подняться, а метка изменения — нет: иначе она становится
|
||||
// меткой касания строки, и запрос «что изменилось с момента X» получает 26
|
||||
// ложных изменений, неотличимых от настоящего досчёта.
|
||||
merge("d2", base.Add(5*time.Minute))
|
||||
if after := updatedAt(); after != before {
|
||||
t.Errorf("метка изменения двинулась без изменения содержимого: %s → %s", before, after)
|
||||
}
|
||||
|
||||
w, err := st.Workout(ctx, "w1")
|
||||
if err != nil {
|
||||
t.Fatalf("чтение тренировки: %v", err)
|
||||
}
|
||||
if w.Delivery != "d2" {
|
||||
t.Fatal("провенанс не обновился — тест проверяет не то")
|
||||
}
|
||||
}
|
||||
@@ -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("расхождение по порядку свёртки не отражено счётчиком удержаний")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@ import (
|
||||
// database/sql.
|
||||
var ErrNotFound = errors.New("запись не найдена")
|
||||
|
||||
// errNoCandidates — выбор победителя позван на пустом множестве. Нарушенный
|
||||
// инвариант вызывающего, а не свойство данных: множество собирается из карты и
|
||||
// пустым быть не может. Ошибкой, а не паникой, потому что путь проходит внутри
|
||||
// свёртки принятой доставки — отказ обязан быть диагностируемым, а не «index
|
||||
// out of range» в стеке фоновой горутины.
|
||||
var errNoCandidates = errors.New("выбор победителя на пустом множестве версий")
|
||||
|
||||
// ErrBusy — база занята, и повторы транзакции этого не пересидели.
|
||||
//
|
||||
// Доменная ошибка, а не код драйвера: на неё ветвится свёртка. Отказ по
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-- +goose Up
|
||||
-- Сколько сущностей с собственным `id` разбор этой доставки пропустил: без
|
||||
-- `id`, с непомерно длинным `id`, с неразбираемой меткой времени или не
|
||||
-- разобравшихся как объект.
|
||||
--
|
||||
-- Заводится не ради отчётности. Ретеншен сырого архива решает «что потеряется,
|
||||
-- если тело удалить», ПО БАЗЕ, и до этой колонки получал ответ «терять нечего»
|
||||
-- ровно там, где потеряна тренировка с маршрутом: сущность в витрину не попала,
|
||||
-- список непокрытых секций пуст, статус `parsed`. Лог здесь не годится — он
|
||||
-- ротируется, а решение об удалении тела необратимо.
|
||||
--
|
||||
-- БЕЗ DEFAULT намеренно: NULL означает «этот разбор пропусков не считал», и это
|
||||
-- НЕ то же, что ноль. Подстановка нуля объявила бы весь исторический журнал
|
||||
-- проверенным — то самое ложное «терять нечего», ради которого колонка и
|
||||
-- заводится, только теперь с видом измерения. Читатель, принимающий по
|
||||
-- счётчику необратимое решение, обязан трактовать NULL как «не удалять».
|
||||
--
|
||||
-- Data-миграции при этом нет, и это сказано числом: прогон всех 118 тел живого
|
||||
-- архива через разбор даёт `noID=0 noTime=0 malformed=0`, то есть корпус
|
||||
-- пропусков не производил и пересворачивать нечего. «Ничего не потерял по
|
||||
-- замеру» и «проверено этим разбором» — разные утверждения, поэтому колонка
|
||||
-- всё равно остаётся NULL до первой пересвёртки.
|
||||
--
|
||||
-- Статус разбора от пропуска сущности не зависит: `partial` определён списком
|
||||
-- непокрытых секций, и второй источник истины для него завёл бы расхождение
|
||||
-- читателей, которое учёт частичного разбора запрещает явно.
|
||||
ALTER TABLE delivery ADD COLUMN skipped_entities INTEGER;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE delivery DROP COLUMN skipped_entities;
|
||||
@@ -108,3 +108,88 @@ func seed(t *testing.T, path string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Откат бинаря поверх новой схемы обязан отказывать, а не стартовать молча:
|
||||
// старый бинарь незнакомые секции игнорирует и доставки за окно отката помечает
|
||||
// разобранными — ничто не намекает, что для этого окна нужна пересборка. Класс
|
||||
// «молчание», и после ретеншена тел окно становится невосстановимым.
|
||||
func TestOpenОтвергаетСхемуИзБудущего(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "healthlog.db")
|
||||
seed(t, path)
|
||||
|
||||
db, err := sql.Open("sqlite", "file:"+path)
|
||||
if err != nil {
|
||||
t.Fatalf("sql.Open: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO goose_db_version (version_id, is_applied, tstamp)
|
||||
VALUES (99, 1, datetime('now'))`); err != nil {
|
||||
t.Fatalf("вставка версии из будущего: %v", err)
|
||||
}
|
||||
_ = db.Close()
|
||||
|
||||
st, err := store.Open(path)
|
||||
if err == nil {
|
||||
_ = st.Close()
|
||||
t.Fatal("Open молча открыл базу со схемой, которой бинарь не знает")
|
||||
}
|
||||
if !errors.Is(err, store.ErrSchemaMismatch) {
|
||||
t.Errorf("Open дал %v, ожидался ErrSchemaMismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Версия базы НИЖЕ версии бинаря отказом быть не должна: ради этого случая
|
||||
// миграции и существуют. Асимметрия только у Open — OpenForRead строг.
|
||||
func TestOpenНоваяБазаМигрирует(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "fresh.db")
|
||||
st, err := store.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open новой базы: %v", err)
|
||||
}
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
// Повторное открытие уже мигрированной базы тоже проходит: current == target.
|
||||
st2, err := store.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("повторный Open: %v", err)
|
||||
}
|
||||
_ = st2.Close()
|
||||
}
|
||||
|
||||
// База без журнала миграций нашей не является, и сказать это надо прямо и
|
||||
// сразу. Через goose такой вопрос стоил бы трёх секунд повторов и ответа
|
||||
// «attempt to write a readonly database» — то есть оператор, спросивший про
|
||||
// версию схемы, получил бы ответ про права на файл.
|
||||
func TestOpenForReadЧужаяБазаОтвергаетсяБыстро(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "alien.db")
|
||||
db, err := sql.Open("sqlite", "file:"+path)
|
||||
if err != nil {
|
||||
t.Fatalf("sql.Open: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`CREATE TABLE t (a INTEGER)`); err != nil {
|
||||
t.Fatalf("создание чужой таблицы: %v", err)
|
||||
}
|
||||
_ = db.Close()
|
||||
|
||||
start := time.Now()
|
||||
st, err := store.OpenForRead(path)
|
||||
if err == nil {
|
||||
_ = st.Close()
|
||||
t.Fatal("чужая база открылась на чтение")
|
||||
}
|
||||
if !errors.Is(err, store.ErrSchemaMismatch) {
|
||||
t.Errorf("OpenForRead дал %v, ожидался ErrSchemaMismatch", err)
|
||||
}
|
||||
// Проверяется свойство «отказ не идёт через повторы записи», а не
|
||||
// конкретная скорость: повторы у goose — три по секунде.
|
||||
if d := time.Since(start); d > time.Second {
|
||||
t.Errorf("отказ занял %v — путь идёт через попытки записи", d)
|
||||
}
|
||||
}
|
||||
|
||||
+100
-38
@@ -9,8 +9,6 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
@@ -27,7 +25,27 @@ type Store struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// Open открывает БД по пути и накатывает миграции.
|
||||
// Open открывает БД по пути, сверяет версию схемы и накатывает миграции.
|
||||
//
|
||||
// Версия базы ВЫШЕ версии бинаря — отказ, а не повод мигрировать. Иначе откат
|
||||
// бинаря проходит молча: старый бинарь поверх новой схемы стартует успешно,
|
||||
// незнакомые секции игнорирует и доставки за окно отката помечает
|
||||
// разобранными — ничто не намекает, что для этого окна нужна пересборка. Класс
|
||||
// «молчание», и цена его растёт вместе с ретеншеном: после удаления тел окно
|
||||
// становится невосстановимым.
|
||||
//
|
||||
// Цена самого отказа названа вслух, потому что она реальна: сервис не
|
||||
// поднимется, а телефон шлёт непрерывно и молча — доставка, не попавшая в
|
||||
// архив, в журнал не попадает вовсе. Выбор сделан так потому, что откат бинаря
|
||||
// это действие оператора, который в этот момент рядом и видит отказ сразу
|
||||
// (контейнер уходит в цикл перезапуска), а дыры плотных метрик за время простоя
|
||||
// закроют широкий и глубокий проходы синхронизации. Не закроют `stateOfMind` —
|
||||
// у него доставки HAE единственный источник; это и есть цена. Она меньше цены
|
||||
// молчания, которое портит витрину за всё окно отката незаметно.
|
||||
//
|
||||
// Версия базы НИЖЕ версии бинаря отказом не является: ради этого случая
|
||||
// миграции и существуют. Асимметрия только здесь — OpenForRead остаётся
|
||||
// строгим.
|
||||
func Open(dbPath string) (*Store, error) {
|
||||
db, err := sqlx.Connect("sqlite", dsn(dbPath))
|
||||
if err != nil {
|
||||
@@ -59,50 +77,77 @@ func OpenForRead(dbPath string) (*Store, error) {
|
||||
return nil, fmt.Errorf("open sqlite %q read-only: %w", dbPath, err)
|
||||
}
|
||||
|
||||
want, err := latestMigration()
|
||||
ctx := context.Background()
|
||||
// Журнал миграций спрашивается ДО goose и структурно, а не по тексту ошибки
|
||||
// драйвера. Причина не в стиле: `GetVersions` при отсутствии таблицы идёт
|
||||
// её СОЗДАВАТЬ, на соединении `mode=ro` это три секунды повторов и отказ
|
||||
// «attempt to write a readonly database» — оператор, спросивший про версию
|
||||
// схемы, получал бы ответ про права на файл. База без журнала миграций
|
||||
// нашей не является, и сказать это надо прямо.
|
||||
ok, err := hasMigrationLog(ctx, db)
|
||||
if err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
var got int64
|
||||
if err := db.Get(&got, `SELECT max(version_id) FROM goose_db_version`); err != nil {
|
||||
if !ok {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("read schema version: %w", err)
|
||||
return nil, fmt.Errorf("%w: журнала миграций в базе нет", ErrSchemaMismatch)
|
||||
}
|
||||
if got != want {
|
||||
|
||||
inDB, inBinary, err := readSchemaVersion(ctx, db)
|
||||
if err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("%w: база %d, бинарь %d", ErrSchemaMismatch, got, want)
|
||||
return nil, err
|
||||
}
|
||||
// Строгое равенство, в отличие от Open: у чтения нет способа догнать схему,
|
||||
// а база старее бинаря отдала бы колонки, которых в ней ещё нет. Так уже
|
||||
// нормировано пересборкой, и настоящее правило её не ослабляет.
|
||||
if inDB != inBinary {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("%w: база %d, бинарь %d", ErrSchemaMismatch, inDB, inBinary)
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
// latestMigration — номер последней миграции, вшитой в бинарь.
|
||||
func latestMigration() (int64, error) {
|
||||
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
||||
// hasMigrationLog говорит, есть ли в базе журнал миграций goose. Структурный
|
||||
// вопрос к самой базе, а не разбор текста ошибки драйвера: сообщения драйвера
|
||||
// контрактом не являются — правило записано в isBusy и действует здесь.
|
||||
func hasMigrationLog(ctx context.Context, db *sqlx.DB) (bool, error) {
|
||||
const q = `SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'goose_db_version'`
|
||||
|
||||
var n int
|
||||
if err := db.GetContext(ctx, &n, q); err != nil {
|
||||
return false, fmt.Errorf("read migration log presence: %w", err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// readSchemaVersion отвечает, какая версия схемы лежит в базе и какую знает
|
||||
// бинарь. Единственное место, где версия ЧИТАЕТСЯ, — сравнивают её два способа
|
||||
// открытия по-разному, а читают одинаково.
|
||||
//
|
||||
// Спрашиваем сам goose, а не собственный `SELECT max(version_id)`: имя таблицы
|
||||
// учёта, имя колонки и правило «максимум = текущая версия» принадлежат ему.
|
||||
// Рукописная копия его приватной схемы разошлась бы при обновлении зависимости,
|
||||
// причём не отказом, а тем, что страж перестал бы ловить, — то есть ровно тем,
|
||||
// что страж и обязан не допускать. Заодно исчезает собственный разбор имён
|
||||
// `NNNNN_*.sql` и вопрос «как отличить пустую таблицу от отсутствующей, не
|
||||
// читая текст ошибки драйвера»: на новой базе goose отдаёт 0 сам.
|
||||
//
|
||||
// Оговорка, без которой обещание непроверяемо: `GetVersions` при ОТСУТСТВИИ
|
||||
// таблицы учёта идёт её создавать. На соединении только для чтения это отказ, и
|
||||
// вызывающий обязан отсеять такую базу раньше (см. hasMigrationLog); на
|
||||
// соединении с записью создание законно — им и начинается новая база.
|
||||
func readSchemaVersion(ctx context.Context, db *sqlx.DB) (inDB, inBinary int64, err error) {
|
||||
p, err := newProvider(db)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read migrations dir: %w", err)
|
||||
return 0, 0, err
|
||||
}
|
||||
var top int64
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
// Неразобранное имя — отказ, а не пропуск: страж «версия схемы не та»,
|
||||
// молча не заметивший миграцию, перестаёт страховать, не сказав об этом.
|
||||
idx := strings.IndexByte(name, '_')
|
||||
if idx <= 0 {
|
||||
return 0, fmt.Errorf("имя миграции %q не вида NNNNN_*.sql", name)
|
||||
}
|
||||
v, err := strconv.ParseInt(name[:idx], 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("имя миграции %q не вида NNNNN_*.sql", name)
|
||||
}
|
||||
if v > top {
|
||||
top = v
|
||||
}
|
||||
inDB, inBinary, err = p.GetVersions(ctx)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("read schema version: %w", err)
|
||||
}
|
||||
if top == 0 {
|
||||
return 0, errors.New("миграций не найдено")
|
||||
}
|
||||
return top, nil
|
||||
return inDB, inBinary, nil
|
||||
}
|
||||
|
||||
// Close закрывает соединение с БД.
|
||||
@@ -150,21 +195,38 @@ func readOnlyDSN(path string) string {
|
||||
// пересборка витрины откроет второе, и хранилище не должно зависеть от того,
|
||||
// что вызывающий этого не сделает.
|
||||
func migrate(db *sqlx.DB) error {
|
||||
sub, err := fs.Sub(migrationsFS, "migrations")
|
||||
ctx := context.Background()
|
||||
|
||||
inDB, inBinary, err := readSchemaVersion(ctx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("goose migrations fs: %w", err)
|
||||
return err
|
||||
}
|
||||
if inDB > inBinary {
|
||||
return fmt.Errorf("%w: база %d, бинарь %d", ErrSchemaMismatch, inDB, inBinary)
|
||||
}
|
||||
|
||||
p, err := goose.NewProvider(goose.DialectSQLite3, db.DB, sub)
|
||||
p, err := newProvider(db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("goose provider: %w", err)
|
||||
return err
|
||||
}
|
||||
if _, err := p.Up(context.Background()); err != nil {
|
||||
if _, err := p.Up(ctx); err != nil {
|
||||
return fmt.Errorf("goose up: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newProvider(db *sqlx.DB) (*goose.Provider, error) {
|
||||
sub, err := fs.Sub(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("goose migrations fs: %w", err)
|
||||
}
|
||||
p, err := goose.NewProvider(goose.DialectSQLite3, db.DB, sub)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("goose provider: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Now — единая точка генерации времени: UTC, секундная точность.
|
||||
// Секунды дают фиксированную ширину RFC 3339, а значит лексикографическая
|
||||
// сортировка TEXT совпадает с хронологией.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package store
|
||||
|
||||
import "context"
|
||||
|
||||
// pickBest выбирает победителя среди кандидатов на одни координаты.
|
||||
//
|
||||
// **Победитель — функция МНОЖЕСТВА кандидатов, а не порядка их поступления.**
|
||||
// Попарная свёртка этого не даёт: полнота (или покрытие) — частичный порядок,
|
||||
// тай-брейк — тотальный, и вместе они образуют нетранзитивное отношение победы,
|
||||
// то есть цикл. При цикле повторная свёртка одной и той же доставки меняет
|
||||
// содержимое витрины, и она перестаёт быть свёрткой журнала. Проверено дважды:
|
||||
// сперва на точках, где нетранзитивность нашлась перебором троек, потом на
|
||||
// сущностях, где ту же ошибку повторили молча.
|
||||
//
|
||||
// Отсюда и общий помощник вместо второй рукописной копии: механизм один,
|
||||
// отношения разные. `architecture.md` уже обещает смену тай-брейка точек, когда
|
||||
// род метрики будет измерен, — то есть правка одного экземпляра при живом
|
||||
// втором запланирована заранее, и расхождение правил слияния ломает детерминизм
|
||||
// свёртки молча.
|
||||
//
|
||||
// dominates(a, b) обязан быть СТРОГИМ превосходством: a не хуже b и b не не
|
||||
// хуже a. Иначе взаимно покрывающие друг друга кандидаты выбьют друг друга, и
|
||||
// множество непревзойдённых окажется пустым.
|
||||
//
|
||||
// less обязан быть ТОТАЛЬНЫМ строгим порядком: при неединственном минимуме
|
||||
// победителем оказывается просто первый в срезе, то есть порядок элементов на
|
||||
// проводе, а он у HAE нестабилен.
|
||||
//
|
||||
// Отбор непревзойдённых КВАДРАТИЧЕН по числу кандидатов, и это названо вслух,
|
||||
// потому что число кандидатов приходит из чужого тела. Отсюда `ctx`: цикл, чья
|
||||
// стоимость определяется размером входа, обязан видеть отмену. Без него тело с
|
||||
// двадцатью тысячами версий одного ключа занимало бы единственного воркера
|
||||
// свёртки дольше, чем длится его же дедлайн, — то есть дедлайн, заведённый
|
||||
// ровно против такого случая, не значил бы ничего. Замерено: n=4000 — 3.9 с,
|
||||
// n=8000 — вчетверо больше.
|
||||
//
|
||||
// Вызывающий обязан сокращать множество до входа сюда: совпавших кандидатов
|
||||
// схлопывать, а число различных — ограничивать. Помощник этого не делает
|
||||
// сам — что считать «тем же» кандидатом, знает только он.
|
||||
//
|
||||
// Возвращает индекс победителя и индексы непревзойдённых — вторые нужны тем,
|
||||
// кто считает несравнимость среди них. Пустой срез кандидатов — нарушенный
|
||||
// инвариант вызывающего: оба сегодняшних вызова собирают множество из карты и
|
||||
// пустого дать не могут.
|
||||
func pickBest[T any](ctx context.Context, cands []T, dominates func(a, b T) bool, less func(a, b T) bool) (winner int, maximal []int, err error) {
|
||||
if len(cands) == 0 {
|
||||
return 0, nil, errNoCandidates
|
||||
}
|
||||
|
||||
maximal = make([]int, 0, len(cands))
|
||||
for i := range cands {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
beaten := false
|
||||
for j := range cands {
|
||||
if i == j {
|
||||
continue
|
||||
}
|
||||
if dominates(cands[j], cands[i]) {
|
||||
beaten = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !beaten {
|
||||
maximal = append(maximal, i)
|
||||
}
|
||||
}
|
||||
|
||||
winner = maximal[0]
|
||||
for _, i := range maximal[1:] {
|
||||
if less(cands[i], cands[winner]) {
|
||||
winner = i
|
||||
}
|
||||
}
|
||||
return winner, maximal, nil
|
||||
}
|
||||
Reference in New Issue
Block a user