полнота точки — множество ключей, победитель — функция множества точек
- отношение победы было нетранзитивным: полнота (частичный порядок) плюс тай-брейк (тотальный) в попарной свёртке давали цикл, из-за которого одна и та же доставка меняла содержимое объекта при каждой пересборке - надмножество побеждает только при совпадении значений общих содержательных ключей: иначе точка без единого измерения вытесняла измерение - Less стал тотальным, isEmpty не материализует значение, имя метрики в координате столкновения обрезается, отпечаток витрины включает units и sealed - на живом архиве строгий no-op: 1737 объектов, содержимое совпало побайтово
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -150,6 +151,12 @@ func TestMergePointsПовторНеПишет(t *testing.T) {
|
||||
if stats.Unchanged != 1 {
|
||||
t.Errorf("объектов без изменений %d, ожидался 1", stats.Unchanged)
|
||||
}
|
||||
// Повтор — не столкновение: точка сравнивается сама с собой, и ни один
|
||||
// счётчик правила слияния расти не должен.
|
||||
if stats.Overwrites != 0 || stats.Incomparable != 0 {
|
||||
t.Errorf("повтор посчитан столкновением: перезаписей %d, несравнимых %d",
|
||||
stats.Overwrites, stats.Incomparable)
|
||||
}
|
||||
|
||||
after, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
||||
if err != nil {
|
||||
@@ -301,6 +308,149 @@ func TestMergePointsБеднаяТочкаНеСтираетБогатую(t *te
|
||||
}
|
||||
}
|
||||
|
||||
// Полнота — множество ключей, а не их число. Счётчик значащих полей давал
|
||||
// сохранённой точке 5 против 2 и стирал настоящее измерение безвозвратно:
|
||||
// восстановить его можно было бы только из сырого архива, пока он жив.
|
||||
func TestMergePointsПоляБезСодержанияНеСтираютИзмерение(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
|
||||
hollow := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"qty":0,"a":0,"b":0,"c":{},"d":[]}`)
|
||||
measured := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"date":"2026-07-31 12:00:00 +0300","qty":123.4}`)
|
||||
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{hollow}, "d1"); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
stats, err := st.MergePoints(ctx, []store.IncomingPoint{measured}, "d2")
|
||||
if err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
|
||||
b, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
||||
if err != nil {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
if got := string(b.Points[0].Raw); got != string(measured.Raw) {
|
||||
t.Errorf("измерение стёрто точкой без содержания: %s", got)
|
||||
}
|
||||
if stats.Incomparable != 0 {
|
||||
t.Errorf("несравнимых %d: наборы сравнимы, содержательных ключей у первой нет",
|
||||
stats.Incomparable)
|
||||
}
|
||||
}
|
||||
|
||||
// Поле с нулевым значением содержания не несёт, но и теряться не должно: при
|
||||
// равном множестве содержательных ключей выигрывает точка со всеми ключами.
|
||||
func TestMergePointsРавноеСодержаниеНеТеряетПоля(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
|
||||
wide := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"date":"d","qty":10,"Min":0,"Max":0}`)
|
||||
narrow := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"date":"d","qty":12}`)
|
||||
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{wide}, "d1"); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{narrow}, "d2"); err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
|
||||
b, err := st.Bucket(ctx, "heart_rate", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
||||
if err != nil {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
if got := string(b.Points[0].Raw); got != string(wide.Raw) {
|
||||
t.Errorf("ключи Min и Max потеряны: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Несравнимые наборы полей на живом потоке не встретились ни разу (0 из 2 897
|
||||
// столкновений), поэтому объединение полей не реализовано. Взамен — наблюдение:
|
||||
// счётчик и координаты объекта, по которым событие можно будет разобрать.
|
||||
func TestMergePointsНесравнимыеНаборыСчитаются(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
|
||||
a := point(t, "blood_glucose", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"qty":5.1}`)
|
||||
b := point(t, "blood_glucose", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
|
||||
`{"mealTime":"До еды"}`)
|
||||
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{a}, "d1"); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
stats, err := st.MergePoints(ctx, []store.IncomingPoint{b}, "d2")
|
||||
if err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
|
||||
if stats.Incomparable != 1 {
|
||||
t.Fatalf("несравнимых %d, ожидался 1", stats.Incomparable)
|
||||
}
|
||||
// Несравнимость — частный случай столкновения: иначе сумма перезаписей за
|
||||
// период перестала бы быть сравнимой с прежней.
|
||||
if stats.Overwrites != 1 {
|
||||
t.Errorf("перезаписей %d, ожидалась 1", stats.Overwrites)
|
||||
}
|
||||
if len(stats.IncomparableAt) != 1 {
|
||||
t.Fatalf("координат %d, ожидалась 1", len(stats.IncomparableAt))
|
||||
}
|
||||
if got := stats.IncomparableAt[0]; got.Metric != "blood_glucose" || got.Layer != "minute" {
|
||||
t.Errorf("координаты объекта не те: %s/%s", got.Metric, got.Layer)
|
||||
}
|
||||
|
||||
got, err := st.Bucket(ctx, "blood_glucose", "minute", ts(t, "2025-06-05T10:00:00Z"))
|
||||
if err != nil {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
if len(got.Points) != 1 {
|
||||
t.Errorf("точек %d, ожидалась 1: правило обязано выбрать одну", len(got.Points))
|
||||
}
|
||||
}
|
||||
|
||||
// Список координат упирается в потолок, счётчик — нет: обрезанный список
|
||||
// остаётся зацепкой для разбора, а масштаб события считает счётчик.
|
||||
func TestMergePointsСчётчикРастётПослеПотолкаКоординат(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const hours = 8
|
||||
var first, second []store.IncomingPoint
|
||||
for h := range hours {
|
||||
at := fmt.Sprintf("2025-06-05T%02d:00:00Z", h)
|
||||
first = append(first, point(t, "blood_glucose", "minute", at, at, `{"qty":5.1}`))
|
||||
second = append(second, point(t, "blood_glucose", "minute", at, at, `{"mealTime":"До еды"}`))
|
||||
}
|
||||
|
||||
if _, err := st.MergePoints(ctx, first, "d1"); err != nil {
|
||||
t.Fatalf("первое слияние: %v", err)
|
||||
}
|
||||
stats, err := st.MergePoints(ctx, second, "d2")
|
||||
if err != nil {
|
||||
t.Fatalf("второе слияние: %v", err)
|
||||
}
|
||||
|
||||
if stats.Incomparable != hours {
|
||||
t.Errorf("несравнимых %d, ожидалось %d: счётчик остановился вместе со списком",
|
||||
stats.Incomparable, hours)
|
||||
}
|
||||
if len(stats.IncomparableAt) >= hours {
|
||||
t.Errorf("координат %d — список не обрезан потолком", len(stats.IncomparableAt))
|
||||
}
|
||||
}
|
||||
|
||||
// source нестабилен: то же измерение приезжает то с одним именем устройства,
|
||||
// то с другим. Он не входит в ключ и не считается полнотой.
|
||||
func TestMergePointsСменаИсточникаНеСоздаётВторуюТочку(t *testing.T) {
|
||||
@@ -529,3 +679,170 @@ func TestMergePointsОтменаНеОставляетПоловины(t *testin
|
||||
t.Errorf("точек %d, ожидалась 1: отмена оставила половинчатое состояние", len(b.Points))
|
||||
}
|
||||
}
|
||||
|
||||
// Отношение победы обязано быть функцией МНОЖЕСТВА точек, а не порядка их
|
||||
// поступления. Попарная свёртка этого не давала: полнота — частичный порядок,
|
||||
// тай-брейк — тотальный, и вместе они образовывали цикл (A ⊃ B по ключам,
|
||||
// B бьёт C тай-брейком, C бьёт A тай-брейком). Из-за цикла одна и та же
|
||||
// доставка, свёрнутая дважды, давала два состояния витрины поочерёдно —
|
||||
// хранилище переставало быть свёрткой журнала.
|
||||
//
|
||||
// Тройка ниже — ровно такая: её нашёл враждебный проход ревью.
|
||||
const (
|
||||
cyclеB = `{"a":1,"b":1}`
|
||||
cyclеC = `{"a":2,"d":1}`
|
||||
cyclеA = `{"a":3,"b":1,"c":1}`
|
||||
)
|
||||
|
||||
func cycleTriple(t *testing.T) []store.IncomingPoint {
|
||||
t.Helper()
|
||||
|
||||
return []store.IncomingPoint{
|
||||
point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", cyclеB),
|
||||
point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", cyclеC),
|
||||
point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", cyclеA),
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePointsПовторнаяСвёрткаНеМеняетСостояние(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
in := cycleTriple(t)
|
||||
|
||||
state := func() (string, string) {
|
||||
t.Helper()
|
||||
|
||||
if _, err := st.MergePoints(ctx, in, "d1"); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
b, err := st.Bucket(ctx, "heart_rate", "raw", ts(t, "2025-06-05T10:00:00Z"))
|
||||
if err != nil {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
fp, err := st.Fingerprint(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("отпечаток: %v", err)
|
||||
}
|
||||
return string(b.Points[0].Raw), fp
|
||||
}
|
||||
|
||||
wantRaw, wantFP := state()
|
||||
for pass := 2; pass <= 4; pass++ {
|
||||
gotRaw, gotFP := state()
|
||||
if gotRaw != wantRaw || gotFP != wantFP {
|
||||
t.Fatalf("свёртка %d той же доставки изменила витрину:\n было %s / %s\n стало %s / %s",
|
||||
pass, wantRaw, wantFP[:16], gotRaw, gotFP[:16])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePointsИсходНеЗависитОтПерестановки(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
in := cycleTriple(t)
|
||||
|
||||
// Все шесть перестановок тройки, и вдобавок разбиение на разные доставки:
|
||||
// в живом приёме точки приходят порознь, в пересборке — вместе.
|
||||
perms := [][]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}}
|
||||
|
||||
winner := func(order []int, split bool) string {
|
||||
st := open(t)
|
||||
if split {
|
||||
for _, i := range order {
|
||||
if _, err := st.MergePoints(ctx, []store.IncomingPoint{in[i]}, "d"); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
batch := make([]store.IncomingPoint, 0, len(order))
|
||||
for _, i := range order {
|
||||
batch = append(batch, in[i])
|
||||
}
|
||||
if _, err := st.MergePoints(ctx, batch, "d"); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
}
|
||||
b, err := st.Bucket(ctx, "heart_rate", "raw", ts(t, "2025-06-05T10:00:00Z"))
|
||||
if err != nil {
|
||||
t.Fatalf("чтение: %v", err)
|
||||
}
|
||||
return string(b.Points[0].Raw)
|
||||
}
|
||||
|
||||
want := winner(perms[0], false)
|
||||
for _, p := range perms {
|
||||
for _, split := range []bool{false, true} {
|
||||
if got := winner(p, split); got != want {
|
||||
t.Errorf("перестановка %v (порознь=%v) дала %s, ожидалось %s", p, split, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Точка, ни одно значение которой не несёт измерения, не должна вытеснять
|
||||
// настоящее измерение. Раньше вытесняла: множества содержательных ключей
|
||||
// равны, и решал второй разряд — по ключам, а не по содержанию.
|
||||
func TestMergePointsПадингНеВытесняетИзмерение(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
const real = `{"date":"d","qty":72.5}`
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
junk string
|
||||
}{
|
||||
{"падинг из null", `{"date":"d","qty":0.001,"p1":null,"p2":null,"p3":null}`},
|
||||
{"падинг из false", `{"date":"d","qty":false,"Min":false,"Max":false}`},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
in := []store.IncomingPoint{
|
||||
point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", real),
|
||||
point(t, "heart_rate", "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", c.junk),
|
||||
}
|
||||
stats, err := st.MergePoints(ctx, in, "d1")
|
||||
if err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
// Правило полноты обязано молчать: это столкновение равнополных,
|
||||
// а не победа надмножества.
|
||||
if stats.Incomparable != 0 {
|
||||
t.Errorf("несравнимых %d, ожидалось 0: наборы содержательных ключей равны", stats.Incomparable)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Имя метрики приходит из тела доставки дословно и ничем не ограничено.
|
||||
// Без обрезки одна доставка порождает WARN-строку в десятки мегабайт и
|
||||
// вытесняет из ротации логов всю недавнюю историю.
|
||||
func TestMergePointsИмяМетрикиВКоординатеОбрезано(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
ctx := context.Background()
|
||||
|
||||
huge := strings.Repeat("м", 5000)
|
||||
in := []store.IncomingPoint{
|
||||
point(t, huge, "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`),
|
||||
point(t, huge, "raw", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":2}`),
|
||||
}
|
||||
stats, err := st.MergePoints(ctx, in, "d1")
|
||||
if err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
if len(stats.Collisions) == 0 {
|
||||
t.Fatal("столкновение не зафиксировано")
|
||||
}
|
||||
if got := len(stats.Collisions[0].Metric); got > 128 {
|
||||
t.Errorf("имя метрики в координате %d байт — оно уедет в лог как есть", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user