полнота точки — множество ключей, победитель — функция множества точек

- отношение победы было нетранзитивным: полнота (частичный порядок) плюс
  тай-брейк (тотальный) в попарной свёртке давали цикл, из-за которого одна
  и та же доставка меняла содержимое объекта при каждой пересборке
- надмножество побеждает только при совпадении значений общих содержательных
  ключей: иначе точка без единого измерения вытесняла измерение
- Less стал тотальным, isEmpty не материализует значение, имя метрики в
  координате столкновения обрезается, отпечаток витрины включает units и sealed
- на живом архиве строгий no-op: 1737 объектов, содержимое совпало побайтово
This commit is contained in:
av
2026-08-01 21:05:43 +03:00
parent 349a227ab1
commit 7a7594e3e7
22 changed files with 1818 additions and 176 deletions
+317
View File
@@ -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)
}
}