полнота точки — множество ключей, победитель — функция множества точек
- отношение победы было нетранзитивным: полнота (частичный порядок) плюс тай-брейк (тотальный) в попарной свёртке давали цикл, из-за которого одна и та же доставка меняла содержимое объекта при каждой пересборке - надмножество побеждает только при совпадении значений общих содержательных ключей: иначе точка без единого измерения вытесняла измерение - Less стал тотальным, isEmpty не материализует значение, имя метрики в координате столкновения обрезается, отпечаток витрины включает units и sealed - на живом архиве строгий no-op: 1737 объектов, содержимое совпало побайтово
This commit is contained in:
+242
-29
@@ -107,34 +107,194 @@ func Equal(a, b []byte) bool {
|
||||
return bytes.Equal(fa, fb)
|
||||
}
|
||||
|
||||
// Completeness — мера полноты точки: сколько значащих полей она несёт.
|
||||
// Fullness — отношение полноты двух точек: несёт ли одна всё, что несёт
|
||||
// другая, и сверх того.
|
||||
//
|
||||
// Нужна правилу разрешения столкновений: по одним координатам приезжают точки
|
||||
// с разным НАБОРОМ полей при одинаковом значении (0.66% координат), и правило
|
||||
// «последняя победила» стирало бы у сохранённой точки поля, которых новая не
|
||||
// несёт.
|
||||
// Полнота — частичный порядок, а не число. Счётчик значащих полей сравним
|
||||
// всегда и потому отвечает там, где ответа нет: точка с пятью полями без
|
||||
// содержания «полнее» настоящего измерения с двумя. Измерено (находка 49):
|
||||
// настоящих столкновений 0.65% координат, из них 981 различаются набором
|
||||
// полей — это и есть область правила, — а несравнимых наборов ноль.
|
||||
//
|
||||
// Поля с пустым значением не считаются: точка с `context: null` не полнее
|
||||
// точки без `context`. Поле source в счёт не идёт — оно нестабильно и
|
||||
// переписывается задним числом, так что его наличие ничего не говорит о
|
||||
// полноте измерения.
|
||||
func Completeness(raw []byte) int {
|
||||
// Нумерация с единицы: нулевое значение не означает ничего. Незаполненное поле
|
||||
// или ранний возврат не должны выглядеть как «множества равны» — это
|
||||
// сегодняшний исход по умолчанию, и отказ маскировался бы под успех.
|
||||
type Fullness int
|
||||
|
||||
const (
|
||||
// FullnessEqual — множества ключей совпадают.
|
||||
FullnessEqual Fullness = iota + 1
|
||||
// FullnessSuperset — a несёт всё, что b, и сверх того.
|
||||
FullnessSuperset
|
||||
// FullnessSubset — b несёт всё, что a, и сверх того.
|
||||
FullnessSubset
|
||||
// FullnessIncomparable — у каждой точки есть ключ, которого нет у другой.
|
||||
FullnessIncomparable
|
||||
)
|
||||
|
||||
func (f Fullness) String() string {
|
||||
switch f {
|
||||
case FullnessEqual:
|
||||
return "equal"
|
||||
case FullnessSuperset:
|
||||
return "superset"
|
||||
case FullnessSubset:
|
||||
return "subset"
|
||||
case FullnessIncomparable:
|
||||
return "incomparable"
|
||||
default:
|
||||
return "unknown(" + strconv.Itoa(int(f)) + ")"
|
||||
}
|
||||
}
|
||||
|
||||
// Fields — ключи точки, разобранные ОДИН раз: множество всех и значения тех,
|
||||
// что несут содержание.
|
||||
//
|
||||
// Разбор вынесен в отдельный тип не ради красоты. Победитель столкновения
|
||||
// выбирается из множества кандидатов, а не парой (см. store), и сравнений там
|
||||
// квадратично по числу кандидатов. Разбирай их RelateFullness каждый раз —
|
||||
// доставка с сотней точек на одной координате разобрала бы каждую сотню раз.
|
||||
type Fields struct {
|
||||
// full — ключ с непустым значением → его исходные байты. Значения нужны
|
||||
// целиком: полнота требует не только наличия ключа, но и совпадения
|
||||
// содержания (см. Relate).
|
||||
full map[string]json.RawMessage
|
||||
// all — все ключи, включая те, чьё значение пусто.
|
||||
all map[string]struct{}
|
||||
}
|
||||
|
||||
// Analyze разбирает точку на множества ключей.
|
||||
//
|
||||
// Поле source не входит ни в одно из множеств: оно нестабильно и
|
||||
// переписывается задним числом (находка 36), так что о полноте измерения
|
||||
// ничего не говорит.
|
||||
//
|
||||
// Содержимое, которое не разбирается как JSON-объект, даёт пустые множества:
|
||||
// так оно проигрывает любой точке с содержанием и не загрязняет наблюдение о
|
||||
// несравнимых наборах.
|
||||
func Analyze(raw []byte) Fields {
|
||||
f := Fields{
|
||||
full: make(map[string]json.RawMessage),
|
||||
all: make(map[string]struct{}),
|
||||
}
|
||||
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
return 0
|
||||
return f
|
||||
}
|
||||
|
||||
n := 0
|
||||
for k, v := range obj {
|
||||
if k == "source" {
|
||||
continue
|
||||
}
|
||||
if isEmpty(v) {
|
||||
f.all[k] = struct{}{}
|
||||
if !isEmpty(v) {
|
||||
f.full[k] = v
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// RelateFullness сравнивает полноту двух точек.
|
||||
//
|
||||
// Обёртка над Analyze и Relate для одиночного сравнения; в слиянии разбор
|
||||
// переиспользуется.
|
||||
func RelateFullness(a, b []byte) Fullness {
|
||||
return Analyze(a).Relate(Analyze(b))
|
||||
}
|
||||
|
||||
// Relate сравнивает полноту: несёт ли одна точка всё, что несёт другая, и
|
||||
// сверх того.
|
||||
//
|
||||
// Разрядов сравнения два. Сперва ключи с НЕПУСТЫМ значением: точка с
|
||||
// `context: null` не полнее точки без `context`. Если они совпали — все ключи:
|
||||
// иначе `{date, qty, Min:0, Max:0}` и `{date, qty}` неразличимы, и `Min` с
|
||||
// `Max` исчезли бы из витрины по жребию тай-брейка.
|
||||
//
|
||||
// «Несёт всё, что несёт другая» — про СОДЕРЖАНИЕ, а не про имена ключей. Если
|
||||
// значения общих содержательных ключей разошлись, точки несут разные
|
||||
// измерения, и надмножество имён о полноте не говорит ничего: иначе
|
||||
// `{qty: 0.001, p1: null, p2: null}` оказывалось бы полнее `{qty: 72.5}` и
|
||||
// стирало настоящее измерение — ровно то, ради отрицания чего правило и
|
||||
// переписано. Такая пара уходит в тай-брейк как равнополная.
|
||||
//
|
||||
// Несравнимость — исход ТОЛЬКО первого разряда: у каждой точки есть
|
||||
// содержательный ключ, которого нет у другой, и объединять там было бы что.
|
||||
// Во втором разряде лишние ключи заведомо пусты, объединять в них нечего, и
|
||||
// счётчик, ради которого объединение полей отложено, не должен считать это
|
||||
// событие (иначе замер «0 из 2 897», снятый по содержательным ключам, теряет
|
||||
// сопоставимость с тем, что считает код).
|
||||
//
|
||||
// Отношение — частичный порядок: антисимметрично по построению и транзитивно
|
||||
// (если A ⊇ B и B ⊇ C, то ключи C ⊆ ключей B ⊆ ключей A, а согласие значений
|
||||
// переносится через B). На это опирается выбор победителя из множества.
|
||||
func (f Fields) Relate(g Fields) Fullness {
|
||||
rel := relateKeys(keysOf(f.full), keysOf(g.full))
|
||||
if rel == FullnessIncomparable {
|
||||
return rel
|
||||
}
|
||||
if !agreeOnShared(f.full, g.full) {
|
||||
return FullnessEqual
|
||||
}
|
||||
if rel != FullnessEqual {
|
||||
return rel
|
||||
}
|
||||
|
||||
if rel2 := relateKeys(f.all, g.all); rel2 == FullnessSuperset || rel2 == FullnessSubset {
|
||||
return rel2
|
||||
}
|
||||
return FullnessEqual
|
||||
}
|
||||
|
||||
// agreeOnShared говорит, совпадают ли значения ключей, содержательных у обеих
|
||||
// точек. Сравнение каноническое: порядок ключей и дребезг последнего разряда
|
||||
// расхождением не считаются.
|
||||
func agreeOnShared(a, b map[string]json.RawMessage) bool {
|
||||
for k, va := range a {
|
||||
vb, ok := b[k]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
if !Equal(va, vb) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return n
|
||||
return true
|
||||
}
|
||||
|
||||
func keysOf(m map[string]json.RawMessage) map[string]struct{} {
|
||||
out := make(map[string]struct{}, len(m))
|
||||
for k := range m {
|
||||
out[k] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// relateKeys сравнивает два множества ключей по включению.
|
||||
func relateKeys(a, b map[string]struct{}) Fullness {
|
||||
aExtra := hasExtra(a, b)
|
||||
bExtra := hasExtra(b, a)
|
||||
|
||||
switch {
|
||||
case aExtra && bExtra:
|
||||
return FullnessIncomparable
|
||||
case aExtra:
|
||||
return FullnessSuperset
|
||||
case bExtra:
|
||||
return FullnessSubset
|
||||
default:
|
||||
return FullnessEqual
|
||||
}
|
||||
}
|
||||
|
||||
// hasExtra говорит, есть ли в a ключ, которого нет в b.
|
||||
func hasExtra(a, b map[string]struct{}) bool {
|
||||
for k := range a {
|
||||
if _, ok := b[k]; !ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Less задаёт детерминированный порядок на точках равной полноты.
|
||||
@@ -144,28 +304,81 @@ func Completeness(raw []byte) int {
|
||||
// ВНУТРИ себя, где время приёма общее. Порядок канонических форм зависит
|
||||
// только от самих значений, поэтому свёртка по журналу даёт то же состояние,
|
||||
// что приём в реальном времени.
|
||||
//
|
||||
// Порядок ТОТАЛЬНЫЙ, включая вход, который не канонизируется: иначе на паре
|
||||
// из двух неразбираемых значений Less(a,b) и Less(b,a) оба давали бы false,
|
||||
// победителем оказывался бы просто второй аргумент, и пересборка журнала
|
||||
// разошлась бы с живым приёмом. Сегодня такой вход недостижим — hae отсеивает
|
||||
// точки, не разбирающиеся в объект, — но станет достижимым со вторым
|
||||
// источником точек (импорт родного экспорта Apple).
|
||||
func Less(a, b []byte) bool {
|
||||
fa, err := Form(a)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
fb, err := Form(b)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return bytes.Compare(fa, fb) < 0
|
||||
return bytes.Compare(SortKey(a), SortKey(b)) < 0
|
||||
}
|
||||
|
||||
// SortKey возвращает то, по чему точки упорядочиваются: каноническую форму,
|
||||
// а для содержимого, которое не канонизируется, — исходные байты.
|
||||
//
|
||||
// Вынесено наружу, чтобы слияние считало форму один раз на точку, а не по разу
|
||||
// на каждое сравнение.
|
||||
func SortKey(raw []byte) []byte {
|
||||
form, err := Form(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return form
|
||||
}
|
||||
|
||||
// isEmpty говорит, несёт ли поле содержание.
|
||||
//
|
||||
// Пусто — `null`, пустая строка, число, равное нулю, пустой объект и пустой
|
||||
// массив. Набор совпадает с `omitempty` из encoding/json минус `false` плюс
|
||||
// пустой объект, и оба отклонения сознательны:
|
||||
//
|
||||
// - `false` пустотой НЕ считается: для булева поля это одно из двух значений,
|
||||
// а не отсутствие сведений (`isIndoor: false` — тренировка на улице).
|
||||
// - Ноль считается: точка, где все значения нулевые, не должна вытеснять
|
||||
// настоящее измерение. Цена названа вслух — при столкновении нулевого
|
||||
// значения с ненулевым по одним координатам выиграет ненулевое, хотя ноль
|
||||
// бывает и настоящим измерением. Речь именно о столкновении, где одно из
|
||||
// двух содержимых заведомо неверно; одиночная нулевая точка хранится как
|
||||
// пришла.
|
||||
//
|
||||
// Значение НЕ материализуется: решение принимается по литералу. Разбор
|
||||
// значения целиком стоил бы разворачивания heartbeatSeries в []any на каждое
|
||||
// сравнение — ровно той формы, от которой разбор тела намеренно отказался
|
||||
// (197 МиБ кучи против 54 МиБ на теле 42 МиБ). При этом `0.0`, `0e0`, `-0` и
|
||||
// `{ }` обязаны считаться пустыми, поэтому по байтам сравнивать тоже нельзя:
|
||||
// число проверяется strconv, скобки — на пробельное содержимое.
|
||||
func isEmpty(v json.RawMessage) bool {
|
||||
t := bytes.TrimSpace(v)
|
||||
switch {
|
||||
case len(t) == 0, bytes.Equal(t, []byte("null")):
|
||||
return true
|
||||
case bytes.Equal(t, []byte(`""`)):
|
||||
lit := bytes.TrimSpace(v)
|
||||
if len(lit) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
switch lit[0] {
|
||||
case 'n': // null
|
||||
return bytes.Equal(lit, []byte("null"))
|
||||
case '"':
|
||||
return bytes.Equal(lit, []byte(`""`))
|
||||
case '{':
|
||||
return emptyBracketed(lit, '{', '}')
|
||||
case '[':
|
||||
return emptyBracketed(lit, '[', ']')
|
||||
case 't', 'f':
|
||||
// Булево — одно из двух значений, а не отсутствие сведений.
|
||||
return false
|
||||
default:
|
||||
f, err := strconv.ParseFloat(string(lit), 64)
|
||||
return err == nil && f == 0
|
||||
}
|
||||
}
|
||||
|
||||
// emptyBracketed говорит, что между скобками нет ничего, кроме пробелов.
|
||||
func emptyBracketed(lit []byte, open, close byte) bool {
|
||||
if len(lit) < 2 || lit[0] != open || lit[len(lit)-1] != close {
|
||||
return false
|
||||
}
|
||||
return len(bytes.TrimSpace(lit[1:len(lit)-1])) == 0
|
||||
}
|
||||
|
||||
// decode разбирает значение с числами в виде json.Number: строковый литерал
|
||||
|
||||
+190
-16
@@ -1,7 +1,9 @@
|
||||
package canon_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/canon"
|
||||
@@ -158,44 +160,216 @@ func TestFormНеСдвигаетБольшиеЦелые(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteness(t *testing.T) {
|
||||
func TestRelateFullness(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want int
|
||||
a string
|
||||
b string
|
||||
want canon.Fullness
|
||||
}{
|
||||
{"пустой объект", `{}`, 0},
|
||||
{"только qty", `{"qty":1}`, 1},
|
||||
{"qty и границы", `{"qty":1,"start":"a","end":"b"}`, 3},
|
||||
{"одинаковые", `{"qty":1}`, `{"qty":1}`, canon.FullnessEqual},
|
||||
{
|
||||
"надмножество", `{"qty":1,"context":"x"}`, `{"qty":1}`,
|
||||
canon.FullnessSuperset,
|
||||
},
|
||||
{
|
||||
"подмножество", `{"qty":1}`, `{"qty":1,"context":"x"}`,
|
||||
canon.FullnessSubset,
|
||||
},
|
||||
{
|
||||
// Тот случай, ради которого правило и переписано: числом ключей он
|
||||
// не выражается вовсе.
|
||||
name: "несравнимые", a: `{"qty":1}`, b: `{"context":"x"}`,
|
||||
want: canon.FullnessIncomparable,
|
||||
},
|
||||
{
|
||||
// source нестабилен и переписывается задним числом, поэтому его
|
||||
// наличие ничего не говорит о полноте измерения.
|
||||
name: "source не считается",
|
||||
raw: `{"qty":1,"source":"Device A"}`,
|
||||
want: 1,
|
||||
a: `{"qty":1,"source":"Device A"}`, b: `{"qty":1}`,
|
||||
want: canon.FullnessEqual,
|
||||
},
|
||||
{
|
||||
// Точка с пустым полем не полнее точки без него — иначе бедная
|
||||
// доставка выиграла бы столкновение одним лишь наличием ключа.
|
||||
name: "пустые значения не считаются",
|
||||
raw: `{"qty":1,"context":null,"note":""}`,
|
||||
want: 1,
|
||||
// Регрессия задачи: пять полей без содержания против настоящего
|
||||
// измерения. Счётчик давал 5 против 2 и стирал измерение.
|
||||
name: "поля без содержания не добавляют полноты",
|
||||
a: `{"qty":0,"a":0,"b":0,"c":{},"d":[]}`,
|
||||
b: `{"date":"2026-07-31 12:00:00 +0300","qty":123.4}`,
|
||||
want: canon.FullnessSubset,
|
||||
},
|
||||
{"не объект", `[1,2,3]`, 0},
|
||||
{
|
||||
// Второй разряд: содержательные ключи те же, но нулевые поля
|
||||
// теряться не должны.
|
||||
// Второй разряд работает именно при РАВНОМ содержании: qty один и
|
||||
// тот же, лишние ключи пусты — терять их незачем.
|
||||
name: "при равном содержании выигрывает набор со всеми ключами",
|
||||
a: `{"date":"d","qty":10,"Min":0,"Max":0}`,
|
||||
b: `{"date":"d","qty":10}`,
|
||||
want: canon.FullnessSuperset,
|
||||
},
|
||||
{
|
||||
// А вот при РАЗНОМ содержании лишние пустые ключи полноты не дают:
|
||||
// точки несут разные измерения, и надмножество имён об этом ничего
|
||||
// не говорит. Иначе точка, где ни одно значение не измерение,
|
||||
// вытесняла бы настоящее измерение.
|
||||
name: "разное содержание не перебивается пустыми ключами",
|
||||
a: `{"date":"d","qty":10,"Min":0,"Max":0}`,
|
||||
b: `{"date":"d","qty":12}`,
|
||||
want: canon.FullnessEqual,
|
||||
},
|
||||
{
|
||||
// Тот же дефект в самой опасной форме: падинг из null. Множества
|
||||
// содержательных ключей равны, поэтому раньше решал второй разряд —
|
||||
// и настоящее измерение проигрывало точке, не несущей измерения.
|
||||
name: "падинг из null не полнее измерения",
|
||||
a: `{"date":"d","qty":0.001,"p1":null,"p2":null,"p3":null}`,
|
||||
b: `{"date":"d","qty":72.5}`,
|
||||
want: canon.FullnessEqual,
|
||||
},
|
||||
{
|
||||
// Надмножество содержательных ключей тоже обязано СОГЛАСОВЫВАТЬСЯ
|
||||
// по общим значениям, иначе `qty:false` побеждало бы `qty:72.5`
|
||||
// одним лишь наличием соседних полей.
|
||||
name: "надмножество с чужим значением полноты не даёт",
|
||||
a: `{"date":"d","qty":false,"Min":false,"Max":false}`,
|
||||
b: `{"date":"d","qty":72.5}`,
|
||||
want: canon.FullnessEqual,
|
||||
},
|
||||
{
|
||||
// Настоящее надмножество: общее значение совпадает, поля добавлены.
|
||||
name: "надмножество с тем же значением полнее",
|
||||
a: `{"date":"d","qty":72.5,"Min":70,"Max":75}`,
|
||||
b: `{"date":"d","qty":72.5}`,
|
||||
want: canon.FullnessSuperset,
|
||||
},
|
||||
{
|
||||
// false — одно из двух значений булева поля, а не отсутствие
|
||||
// сведений: `isIndoor: false` это тренировка на улице.
|
||||
name: "false содержателен",
|
||||
a: `{"qty":1,"isIndoor":false}`, b: `{"qty":1}`,
|
||||
want: canon.FullnessSuperset,
|
||||
},
|
||||
{
|
||||
// Пустота считается по разобранному значению: будь она побайтовой,
|
||||
// эти поля прошли бы как содержательные и набор стал бы несравнимым.
|
||||
name: "запись нуля и пустоты роли не играет",
|
||||
a: `{"qty":1,"a":0.0,"b":-0,"c":0e0,"d":{ },"e":[ ],"f":""}`,
|
||||
b: `{"qty":1,"context":"x"}`,
|
||||
want: canon.FullnessSubset,
|
||||
},
|
||||
{
|
||||
// Ключ без содержания всё же лучше его отсутствия — но только когда
|
||||
// содержательные множества уже сравнялись.
|
||||
name: "лишний пустой ключ решает вторым разрядом",
|
||||
a: `{"qty":1,"Min":0}`, b: `{"qty":1}`,
|
||||
want: canon.FullnessSuperset,
|
||||
},
|
||||
{
|
||||
// Граница пустоты проведена по содержанию, а не по «похоже на
|
||||
// пустое»: пробел, строка "0" и контейнер с элементом — содержание.
|
||||
name: "похожее на пустоту содержательно",
|
||||
a: `{"qty":1,"a":" ","b":"0","c":[null],"d":{"x":null}}`,
|
||||
b: `{"qty":1}`,
|
||||
want: canon.FullnessSuperset,
|
||||
},
|
||||
{"не объект против точки", `[1,2,3]`, `{"qty":1}`, canon.FullnessSubset},
|
||||
{"оба не объекты", `[1,2,3]`, `"строка"`, canon.FullnessEqual},
|
||||
{"невалидный JSON", `{"qty":`, `{"qty":1}`, canon.FullnessSubset},
|
||||
{"пустой вход", ``, ``, canon.FullnessEqual},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := canon.Completeness([]byte(c.raw)); got != c.want {
|
||||
t.Errorf("полнота %s = %d, ожидалось %d", c.raw, got, c.want)
|
||||
|
||||
got := canon.RelateFullness([]byte(c.a), []byte(c.b))
|
||||
if got != c.want {
|
||||
t.Errorf("полнота %s против %s = %s, ожидалось %s",
|
||||
c.a, c.b, got, c.want)
|
||||
}
|
||||
|
||||
// Отношение обязано быть симметричным: свёртка по журналу не знает,
|
||||
// какая из точек «первая».
|
||||
want := mirror(c.want)
|
||||
if got := canon.RelateFullness([]byte(c.b), []byte(c.a)); got != want {
|
||||
t.Errorf("обратный порядок = %s, ожидалось %s", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mirror(f canon.Fullness) canon.Fullness {
|
||||
switch f {
|
||||
case canon.FullnessSuperset:
|
||||
return canon.FullnessSubset
|
||||
case canon.FullnessSubset:
|
||||
return canon.FullnessSuperset
|
||||
default:
|
||||
return f
|
||||
}
|
||||
}
|
||||
|
||||
// Полнота смотрит на наличие содержания, а не на форму записи: значения,
|
||||
// различающиеся дребезгом последнего разряда, обязаны давать равные множества.
|
||||
func TestRelateFullnessУстойчивКФормеЗаписи(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := []byte(`{"qty":0.09523182962471353,"date":"d"}`)
|
||||
b := []byte(` { "date" : "d" , "qty" : 0.09523182962471352 } `)
|
||||
|
||||
if got := canon.RelateFullness(a, b); got != canon.FullnessEqual {
|
||||
t.Errorf("полнота = %s, ожидалось %s", got, canon.FullnessEqual)
|
||||
}
|
||||
if !canon.Equal(a, b) {
|
||||
t.Error("канонические формы разошлись — тест проверяет не то")
|
||||
}
|
||||
}
|
||||
|
||||
// Нулевое значение типа не должно совпадать ни с одним исходом: забытое поле
|
||||
// или ранний возврат не выглядят как «множества равны».
|
||||
func TestFullnessНулевоеЗначениеНеИсход(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var zero canon.Fullness
|
||||
for _, f := range []canon.Fullness{
|
||||
canon.FullnessEqual, canon.FullnessSuperset,
|
||||
canon.FullnessSubset, canon.FullnessIncomparable,
|
||||
} {
|
||||
if f == zero {
|
||||
t.Errorf("исход %s совпал с нулевым значением", f)
|
||||
}
|
||||
if f.String() == zero.String() {
|
||||
t.Errorf("имя исхода %s совпало с именем нулевого значения", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Точка HAE несёт до нескольких десятков полей, но правило обязано быть
|
||||
// тотальным и на неправдоподобном входе: паника здесь остановила бы разбор
|
||||
// доставки целиком.
|
||||
func TestRelateFullnessНеПаникуетНаБольшомВходе(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteByte('{')
|
||||
for i := range 5000 {
|
||||
if i > 0 {
|
||||
buf.WriteByte(',')
|
||||
}
|
||||
fmt.Fprintf(&buf, `"k%d":%d`, i, i)
|
||||
}
|
||||
buf.WriteByte('}')
|
||||
|
||||
if got := canon.RelateFullness(buf.Bytes(), []byte(`{"k1":1}`)); got != canon.FullnessSuperset {
|
||||
t.Errorf("полнота = %s, ожидалось %s", got, canon.FullnessSuperset)
|
||||
}
|
||||
if got := canon.RelateFullness(nil, nil); got != canon.FullnessEqual {
|
||||
t.Errorf("полнота на nil = %s, ожидалось %s", got, canon.FullnessEqual)
|
||||
}
|
||||
}
|
||||
|
||||
// Тай-брейк обязан зависеть только от значений: свёртка по журналу должна
|
||||
// давать то же состояние, что приём в реальном времени, а внутри одной
|
||||
// доставки время приёма у столкнувшихся точек общее.
|
||||
|
||||
@@ -72,6 +72,7 @@ type Stats struct {
|
||||
Buckets int
|
||||
Unchanged int
|
||||
Overwrites int
|
||||
Incomparable int
|
||||
SealedHits int
|
||||
UnitsConflicts int
|
||||
SkippedNoTime int
|
||||
@@ -80,6 +81,7 @@ type Stats struct {
|
||||
Layer string
|
||||
LayerMismatch bool
|
||||
Collisions []store.Collision
|
||||
IncomparableAt []store.Collision
|
||||
}
|
||||
|
||||
// Fold разбирает тело доставки и раскладывает точки по часовым объектам.
|
||||
@@ -138,9 +140,11 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (Stats, error) {
|
||||
stats.Buckets = merge.Buckets
|
||||
stats.Unchanged = merge.Unchanged
|
||||
stats.Overwrites = merge.Overwrites
|
||||
stats.Incomparable = merge.Incomparable
|
||||
stats.SealedHits = merge.SealedHits
|
||||
stats.UnitsConflicts = merge.UnitsConflicts
|
||||
stats.Collisions = merge.Collisions
|
||||
stats.IncomparableAt = merge.IncomparableAt
|
||||
|
||||
if err := s.finish(ctx, deliveryID, store.ParseDone, int64(stats.Points), stats.Layer); err != nil {
|
||||
s.log.ErrorContext(ctx, "delivery fold failed", "error", err, "delivery_id", deliveryID)
|
||||
@@ -173,6 +177,7 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
|
||||
"buckets", st.Buckets,
|
||||
"unchanged", st.Unchanged,
|
||||
"overwrites", st.Overwrites,
|
||||
"incomparable", st.Incomparable,
|
||||
"units_conflicts", st.UnitsConflicts,
|
||||
"sealed_hits", st.SealedHits,
|
||||
"skipped", skipped,
|
||||
@@ -185,6 +190,9 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
|
||||
if len(st.Collisions) > 0 {
|
||||
attrs = append(attrs, "collisions", formatCollisions(st.Collisions))
|
||||
}
|
||||
if len(st.IncomparableAt) > 0 {
|
||||
attrs = append(attrs, "incomparable_at", formatCollisions(st.IncomparableAt))
|
||||
}
|
||||
|
||||
// Доставка, у которой отброшены ВСЕ точки, — это сломавшийся формат, а не
|
||||
// штатная работа. Без этого условия смена формата метки выглядела бы как
|
||||
@@ -192,6 +200,11 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
|
||||
allSkipped := st.Points == 0 && skipped > 0
|
||||
|
||||
switch {
|
||||
case st.Incomparable > 0:
|
||||
// Выше перезаписей намеренно: несравнимый набор полей — событие реже и
|
||||
// информативнее, на живом потоке не случавшееся ни разу. Признаки при
|
||||
// этом идут атрибутами всегда, так что выбор ветви ничего не прячет.
|
||||
s.log.WarnContext(ctx, "delivery folded, incomparable point fields", attrs...)
|
||||
case st.Overwrites > 0:
|
||||
// Единственное наблюдение, по которому проверяется правило слияния.
|
||||
// В INFO оно тонуло: поток идёт раз в пять минут.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -113,3 +114,71 @@ func TestFoldВсеТочкиОтброшеныДаётWarn(t *testing.T) {
|
||||
t.Errorf("доставка без единой сохранённой точки записана не как WARN:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Несравнимые наборы полей — то самое событие, ради наблюдения за которым
|
||||
// объединение полей не реализовано вовсе. Если оно когда-нибудь наступит, его
|
||||
// обязано быть видно, а не додумано задним числом.
|
||||
func TestFoldНесравнимыеНаборыДаютWarn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
|
||||
dir := t.TempDir()
|
||||
arch, err := archive.New(filepath.Join(dir, "raw"))
|
||||
if err != nil {
|
||||
t.Fatalf("архив: %v", err)
|
||||
}
|
||||
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("база: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
f := fold.New(arch, st, 0, log)
|
||||
ctx := context.Background()
|
||||
|
||||
const body = `{"data":{"metrics":[{"name":"blood_glucose","units":"mg/dL","data":[` +
|
||||
`{"date":"2025-06-05 10:00:00 +0300",%s}` +
|
||||
`]}]}}`
|
||||
|
||||
deliver(t, arch, st, "d1", "Minutes", "auto-1", []byte(fmt.Sprintf(body, `"qty":5.1`)))
|
||||
if _, err := f.Fold(ctx, "d1"); err != nil {
|
||||
t.Fatalf("первая свёртка: %v", err)
|
||||
}
|
||||
buf.Reset()
|
||||
|
||||
deliver(t, arch, st, "d2", "Minutes", "auto-1", []byte(fmt.Sprintf(body, `"mealTime":"До еды"`)))
|
||||
stats, err := f.Fold(ctx, "d2")
|
||||
if err != nil {
|
||||
t.Fatalf("вторая свёртка: %v", err)
|
||||
}
|
||||
if stats.Incomparable != 1 {
|
||||
t.Fatalf("несравнимых %d, ожидался 1: тест проверяет не то", stats.Incomparable)
|
||||
}
|
||||
|
||||
var rec map[string]any
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &rec); err != nil {
|
||||
t.Fatalf("строка лога не JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
if rec["level"] != "WARN" {
|
||||
t.Errorf("уровень %v, ожидался WARN:\n%s", rec["level"], buf.String())
|
||||
}
|
||||
if rec["msg"] != "delivery folded, incomparable point fields" {
|
||||
t.Errorf("сообщение %v не называет событие", rec["msg"])
|
||||
}
|
||||
if rec["incomparable"] != float64(1) {
|
||||
t.Errorf("счётчик несравнимых %v, ожидался 1", rec["incomparable"])
|
||||
}
|
||||
// Координаты объекта — не значения: метрика, слой и час.
|
||||
at, _ := rec["incomparable_at"].(string)
|
||||
if !strings.Contains(at, "blood_glucose/minute@") {
|
||||
t.Errorf("координаты объекта в записи не те: %q", at)
|
||||
}
|
||||
// И ни одного значения точки: данные о здоровье чувствительнее токенов.
|
||||
for _, secret := range []string{"5.1", "До еды"} {
|
||||
if strings.Contains(buf.String(), secret) {
|
||||
t.Errorf("в логе оказалось значение точки %q:\n%s", secret, buf.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestReplayЖивогоАрхива(t *testing.T) {
|
||||
f, arch, st := newFold(t)
|
||||
ctx := context.Background()
|
||||
|
||||
var folded, failed int
|
||||
var folded, failed, incomparable int
|
||||
for _, path := range bodies {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -55,14 +55,21 @@ func TestReplayЖивогоАрхива(t *testing.T) {
|
||||
id := strings.TrimSuffix(filepath.Base(path), ".json.gz")
|
||||
deliver(t, arch, st, id, "", "auto", gunzip(t, body))
|
||||
|
||||
if _, err := f.Fold(ctx, id); err != nil {
|
||||
res, err := f.Fold(ctx, id)
|
||||
if err != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
folded++
|
||||
incomparable += res.Incomparable
|
||||
}
|
||||
|
||||
t.Logf("доставок %d: свёрнуто %d, не свёрнуто %d", len(bodies), folded, failed)
|
||||
// Несравнимые наборы полей — посылка, на которой стоит отказ от объединения
|
||||
// полей: их не было ни разу на всём корпусе. Число печатается, а не
|
||||
// проверяется: появление такого набора — событие для разбора, а не отказ
|
||||
// сходимости.
|
||||
t.Logf("доставок %d: свёрнуто %d, не свёрнуто %d, несравнимых наборов %d",
|
||||
len(bodies), folded, failed, incomparable)
|
||||
|
||||
if folded == 0 {
|
||||
t.Fatal("ни одна доставка не свернулась")
|
||||
@@ -70,10 +77,19 @@ func TestReplayЖивогоАрхива(t *testing.T) {
|
||||
|
||||
// Повторный прогон того же журнала не меняет состояния: свёртка
|
||||
// детерминирована, и пересборка даёт то же, что живой приём.
|
||||
//
|
||||
// Сравнивается ОТПЕЧАТОК содержимого, а не число объектов: на координате
|
||||
// всегда лежит ровно одна точка, и правило разрешения столкновений выбирает,
|
||||
// какая это будет точка, а не сколько их. Счёт объектов совпал бы и при
|
||||
// заведомо сломанном правиле.
|
||||
before, err := st.CountBuckets(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("счёт объектов: %v", err)
|
||||
}
|
||||
fingerprintBefore, err := st.Fingerprint(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("отпечаток: %v", err)
|
||||
}
|
||||
var refolded int
|
||||
for _, path := range bodies {
|
||||
if _, err := f.Fold(ctx, strings.TrimSuffix(filepath.Base(path), ".json.gz")); err != nil {
|
||||
@@ -92,6 +108,20 @@ func TestReplayЖивогоАрхива(t *testing.T) {
|
||||
if before != after {
|
||||
t.Errorf("повторный прогон журнала изменил число объектов: %d → %d", before, after)
|
||||
}
|
||||
fingerprintAfter, err := st.Fingerprint(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("отпечаток: %v", err)
|
||||
}
|
||||
if fingerprintBefore != fingerprintAfter {
|
||||
t.Errorf("повторный прогон журнала изменил содержимое объектов:\n %s\n %s",
|
||||
fingerprintBefore, fingerprintAfter)
|
||||
}
|
||||
|
||||
// Отпечаток печатается всегда: это единственный способ сравнить состояние с
|
||||
// тем, что давала прежняя редакция правила слияния. Эталон в репозитории не
|
||||
// живёт — он производен от архива, которого нет ни на одной другой машине.
|
||||
// Значений точек отпечаток не раскрывает: содержимое входит в него хешем.
|
||||
t.Logf("объектов %d, отпечаток содержимого %s", after, fingerprintAfter)
|
||||
|
||||
// Главное измеренное число: ключ по метке дал бы 170 координат сна, ключ по
|
||||
// интервалу — 174 (docs/local-research.md, находка 47). Если координата
|
||||
|
||||
+207
-44
@@ -4,7 +4,9 @@ import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -58,13 +60,23 @@ type MergeStats struct {
|
||||
// сохранённых. Молчаливая замена недопустима: внутри точки единиц нет, и
|
||||
// у ранних точек не остаётся ничего, по чему их единицы восстановимы.
|
||||
UnitsConflicts int
|
||||
// Incomparable — столкновения, где наборы полей несравнимы: у каждой точки
|
||||
// есть содержательный ключ, которого нет у другой. Правило полноты тут
|
||||
// бессильно, и вместо объединения полей — которого на живом потоке не
|
||||
// потребовалось ни разу — заведено наблюдение. Несравнимость считается
|
||||
// СВЕРХ Overwrites, а не вместо: иначе сумма перезаписей за период
|
||||
// перестала бы быть сравнимой с прежней.
|
||||
Incomparable int
|
||||
// Collisions — координаты первых столкновений, для записи в лог. Без них
|
||||
// счётчик перезаписей не говорит, какая метрика и какой час пострадали.
|
||||
Collisions []Collision
|
||||
// IncomparableAt — то же для несравнимых наборов.
|
||||
IncomparableAt []Collision
|
||||
}
|
||||
|
||||
// Collision — координаты объекта, где содержимое точки было перезаписано.
|
||||
// Значений точек не несёт: данные о здоровье чувствительнее токенов.
|
||||
// Collision — координаты объекта, где столкновение разрешилось перезаписью
|
||||
// содержимого точки. Значений точек не несёт: данные о здоровье чувствительнее
|
||||
// токенов.
|
||||
type Collision struct {
|
||||
Metric string
|
||||
Layer string
|
||||
@@ -75,6 +87,21 @@ type Collision struct {
|
||||
// Больше горсти не нужно: они нужны как зацепка для разбора, а не как отчёт.
|
||||
const maxCollisionsReported = 5
|
||||
|
||||
// maxMetricInLog — предел длины имени метрики в координате столкновения.
|
||||
//
|
||||
// Имя приходит из тела доставки дословно и ничем не ограничено, а предел
|
||||
// приёма — 64 МиБ: без обрезки одна доставка порождает WARN-строку в десятки
|
||||
// мегабайт и вытесняет из ротации логов всю недавнюю историю, включая записи о
|
||||
// доставках, которые действительно потерялись. Имена метрик HAE — десятки байт.
|
||||
const maxMetricInLog = 64
|
||||
|
||||
func clipMetric(metric string) string {
|
||||
if len(metric) <= maxMetricInLog {
|
||||
return metric
|
||||
}
|
||||
return metric[:maxMetricInLog] + "…"
|
||||
}
|
||||
|
||||
// MergePoints раскладывает точки по часовым объектам и сливает их с
|
||||
// сохранёнными.
|
||||
//
|
||||
@@ -110,6 +137,7 @@ func (s *Store) MergePoints(ctx context.Context, in []IncomingPoint, deliveryID
|
||||
stats.Buckets++
|
||||
stats.Stored += res.stored
|
||||
stats.Overwrites += res.overwrites
|
||||
stats.Incomparable += res.incomparable
|
||||
if res.unchanged {
|
||||
stats.Unchanged++
|
||||
}
|
||||
@@ -119,10 +147,15 @@ func (s *Store) MergePoints(ctx context.Context, in []IncomingPoint, deliveryID
|
||||
if res.unitsConflict {
|
||||
stats.UnitsConflicts++
|
||||
}
|
||||
// Список координат упирается в потолок, счётчики — нет: обрезанный
|
||||
// список остаётся зацепкой для разбора, а масштаб события считает
|
||||
// счётчик.
|
||||
coord := Collision{Metric: clipMetric(key.metric), Layer: key.layer, HourUTC: key.hourUTC}
|
||||
if res.overwrites > 0 && len(stats.Collisions) < maxCollisionsReported {
|
||||
stats.Collisions = append(stats.Collisions, Collision{
|
||||
Metric: key.metric, Layer: key.layer, HourUTC: key.hourUTC,
|
||||
})
|
||||
stats.Collisions = append(stats.Collisions, coord)
|
||||
}
|
||||
if res.incomparable > 0 && len(stats.IncomparableAt) < maxCollisionsReported {
|
||||
stats.IncomparableAt = append(stats.IncomparableAt, coord)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -192,6 +225,7 @@ func groupByHour(in []IncomingPoint) map[bucketKey]*pointGroup {
|
||||
|
||||
type mergeResult struct {
|
||||
overwrites int
|
||||
incomparable int
|
||||
stored int
|
||||
unchanged bool
|
||||
sealed bool
|
||||
@@ -211,8 +245,9 @@ func mergeBucket(ctx context.Context, tx *sql.Tx, key bucketKey, group *pointGro
|
||||
return res, err
|
||||
}
|
||||
|
||||
merged, overwrites := mergePoints(stored.Points, group.points)
|
||||
merged, overwrites, incomparable := mergePoints(stored.Points, group.points)
|
||||
res.overwrites = overwrites
|
||||
res.incomparable = incomparable
|
||||
// Считаем сохранённые точки, а не присланные: точные повторы внутри
|
||||
// доставки схлопываются, и счётчик присланных систематически завышал бы
|
||||
// содержимое витрины — расхождение «прислали 1000, лежит 700» было бы
|
||||
@@ -253,85 +288,169 @@ func mergeBucket(ctx context.Context, tx *sql.Tx, key bucketKey, group *pointGro
|
||||
// mergePoints сливает сохранённые точки с пришедшими по координатному ключу.
|
||||
//
|
||||
// При столкновении выигрывает БОЛЕЕ ПОЛНАЯ точка, а не последняя пришедшая:
|
||||
// 0.66% координат различаются набором полей при одинаковом значении, и правило
|
||||
// «последняя победила» стирало бы у сохранённой точки поля, которых новая не
|
||||
// несёт.
|
||||
// 981 столкновение из 2 897 различается набором полей, и правило «последняя
|
||||
// победила» стирало бы у сохранённой точки поля, которых новая не несёт.
|
||||
//
|
||||
// При равной полноте исход определяется порядком канонических форм, а не
|
||||
// порядком доставок: у сохранённой точки нет провенанса, а четверть доставок
|
||||
// несёт столкновения внутри себя, где время приёма общее. Свёртка по журналу
|
||||
// обязана давать то же состояние, что приём в реальном времени.
|
||||
//
|
||||
// Второй счётчик — несравнимые наборы полей. Они и есть та часть правила,
|
||||
// которую заменили наблюдением: объединять поля никто не будет, пока счётчик
|
||||
// не заговорит.
|
||||
//
|
||||
// Точки из объекта не удаляются никогда.
|
||||
func mergePoints(stored, incoming []Point) ([]Point, int) {
|
||||
func mergePoints(stored, incoming []Point) (merged []Point, overwrites, incomparable int) {
|
||||
type coord struct {
|
||||
start int64
|
||||
end int64
|
||||
}
|
||||
|
||||
byCoord := make(map[coord]Point, len(stored)+len(incoming))
|
||||
byCoord := make(map[coord][]candidate, len(stored)+len(incoming))
|
||||
order := make([]coord, 0, len(stored)+len(incoming))
|
||||
|
||||
put := func(p Point) int {
|
||||
// Кандидаты копятся, а не сворачиваются попарно. Попарная свёртка была
|
||||
// НЕВЕРНА: полнота — частичный порядок, тай-брейк — тотальный, и их
|
||||
// смешение даёт нетранзитивное отношение победы. Оно образует цикл
|
||||
// (A ⊃ B по ключам, B бьёт C тай-брейком, C бьёт A тай-брейком), после
|
||||
// чего исход зависит от того, что уже лежало в объекте: одна и та же
|
||||
// доставка, свёрнутая дважды, давала два разных состояния витрины
|
||||
// поочерёдно. Это ломало главный инвариант — «состояние пересобираемо».
|
||||
add := func(p Point) {
|
||||
c := coord{start: p.Start.UnixNano(), end: p.End.UnixNano()}
|
||||
old, ok := byCoord[c]
|
||||
if !ok {
|
||||
byCoord[c] = p
|
||||
return 0
|
||||
cands, seen := byCoord[c]
|
||||
if !seen {
|
||||
order = append(order, c)
|
||||
}
|
||||
// Побайтовое равенство — только быстрый путь. Столкновением считается
|
||||
// расхождение КАНОНИЧЕСКИХ форм: канонизация и заведена потому, что
|
||||
cand := newCandidate(p)
|
||||
// Побайтовое равенство — только быстрый путь. Тем же самым считается
|
||||
// совпадение КАНОНИЧЕСКИХ форм: канонизация и заведена потому, что
|
||||
// байты нестабильны. Из 81952 повторно приехавших точек 67534
|
||||
// различаются лишь порядком ключей, ещё 63% — последним разрядом
|
||||
// double. Считай мы по байтам, счётчик перезаписей давал бы тысячи
|
||||
// ложных срабатываний на каждом глубоком проходе, и настоящий отказ
|
||||
// правила слияния стал бы неотличим от нормы.
|
||||
if bytes.Equal(old.Raw, p.Raw) || canon.Equal(old.Raw, p.Raw) {
|
||||
return 0
|
||||
for _, q := range cands {
|
||||
if bytes.Equal(q.key, cand.key) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
byCoord[c] = resolve(old, p)
|
||||
return 1
|
||||
byCoord[c] = append(cands, cand)
|
||||
}
|
||||
|
||||
overwrites := 0
|
||||
for _, p := range stored {
|
||||
put(p)
|
||||
add(p)
|
||||
}
|
||||
for _, p := range incoming {
|
||||
overwrites += put(p)
|
||||
add(p)
|
||||
}
|
||||
|
||||
out := make([]Point, 0, len(order))
|
||||
for _, c := range order {
|
||||
cands := byCoord[c]
|
||||
winner, unrelated := resolve(cands)
|
||||
// Перезаписей столько, сколько точек уступило: при двух кандидатах
|
||||
// одна, при трёх две. Так счёт остаётся сравнимым с прежним, где
|
||||
// столкновение считалось на каждую приехавшую точку.
|
||||
overwrites += len(cands) - 1
|
||||
if unrelated {
|
||||
incomparable++
|
||||
}
|
||||
out = append(out, winner)
|
||||
}
|
||||
|
||||
// Порядок точек в объекте канонический и входит в хеш: его задаёт ТОЛЬКО
|
||||
// сортировка ниже. Порядок обхода карты не специфицирован, и полагаться на
|
||||
// него значило бы получать разные хеши для одного содержимого — тогда
|
||||
// «неизменившийся» объект переписывался бы каждым глубоким проходом.
|
||||
out := make([]Point, 0, len(byCoord))
|
||||
for _, p := range byCoord {
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if !out[i].Start.Equal(out[j].Start) {
|
||||
return out[i].Start.Before(out[j].Start)
|
||||
}
|
||||
return out[i].End.Before(out[j].End)
|
||||
})
|
||||
return out, overwrites
|
||||
return out, overwrites, incomparable
|
||||
}
|
||||
|
||||
// resolve выбирает победителя столкновения: сперва по полноте, затем
|
||||
// детерминированно по канонической форме.
|
||||
func resolve(a, b Point) Point {
|
||||
ca, cb := canon.Completeness(a.Raw), canon.Completeness(b.Raw)
|
||||
switch {
|
||||
case ca > cb:
|
||||
return a
|
||||
case cb > ca:
|
||||
return b
|
||||
case canon.Less(a.Raw, b.Raw):
|
||||
return a
|
||||
default:
|
||||
return b
|
||||
// candidate — точка вместе с тем, что о ней нужно знать при выборе
|
||||
// победителя. Разбор и канонизация делаются ОДИН раз на точку: сравнений
|
||||
// квадратично по числу кандидатов, и пересчёт на каждое сравнение означал бы
|
||||
// разбор точки столько раз, сколько на координате кандидатов.
|
||||
type candidate struct {
|
||||
pt Point
|
||||
key []byte // каноническая форма: она же ключ дедупликации и порядок
|
||||
fields canon.Fields
|
||||
}
|
||||
|
||||
func newCandidate(p Point) candidate {
|
||||
return candidate{pt: p, key: canon.SortKey(p.Raw), fields: canon.Analyze(p.Raw)}
|
||||
}
|
||||
|
||||
// resolve выбирает победителя среди кандидатов одной координаты.
|
||||
//
|
||||
// Победитель — функция МНОЖЕСТВА кандидатов, а не порядка их поступления.
|
||||
// Сперва отбрасываются те, кого превосходит по полноте кто-то другой
|
||||
// (полнота — частичный порядок, поэтому «непревзойдённые» определены
|
||||
// однозначно), затем среди оставшихся берётся минимум по каноническому
|
||||
// порядку — он тотальный, поэтому минимум единственен. Обе операции зависят
|
||||
// только от состава множества, поэтому пересборка журнала даёт то же
|
||||
// состояние, что живой приём, а повторная свёртка той же доставки не меняет
|
||||
// ничего.
|
||||
//
|
||||
// Победителем остаётся одна из пришедших точек ДОСЛОВНО: правило выбирает, а
|
||||
// не конструирует. Каноническая форма существует только в момент сравнения, и
|
||||
// вернуть её значило бы сохранить округлённое число вместо присланного.
|
||||
//
|
||||
// Второй возврат — остались ли непревзойдёнными несколько точек с
|
||||
// несравнимыми наборами содержательных полей. На живом потоке этого не
|
||||
// случилось ни разу (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
|
||||
}
|
||||
}
|
||||
|
||||
// Несравнимость — не «осталось больше одного»: точки с одинаковыми
|
||||
// наборами полей и разными значениями тоже остаются обе, и это рядовой
|
||||
// тай-брейк. Считается только то, ради чего отложено объединение полей:
|
||||
// у каждой из двух есть содержательный ключ, которого нет у другой.
|
||||
return best.pt, hasIncomparablePair(maximal)
|
||||
}
|
||||
|
||||
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 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hashPoints(points []Point) (string, error) {
|
||||
@@ -580,6 +699,50 @@ func (s *Store) CountBuckets(ctx context.Context) (int64, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Fingerprint возвращает отпечаток содержимого витрины: SHA-256 по координатам
|
||||
// и хешам всех объектов в детерминированном порядке.
|
||||
//
|
||||
// Нужен проверке сходимости на живом архиве. Число объектов и число точек к
|
||||
// правилу разрешения столкновений нечувствительны: на координате всегда лежит
|
||||
// ровно одна точка, и правило выбирает, КАКАЯ это будет точка, а не сколько их.
|
||||
// Значит «объектов столько же» совпадёт и при заведомо сломанном правиле, а
|
||||
// отпечаток — нет.
|
||||
//
|
||||
// Значений точек он не раскрывает: содержимое участвует только своим хешем.
|
||||
func (s *Store) Fingerprint(ctx context.Context) (string, error) {
|
||||
const q = `
|
||||
SELECT metric, layer, hour_utc, content_hash, points, units, sealed FROM bucket
|
||||
ORDER BY metric, layer, hour_utc`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("select buckets: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
h := sha256.New()
|
||||
for rows.Next() {
|
||||
var metric, layer, hour, hash, units string
|
||||
var points int
|
||||
var sealed bool
|
||||
if err := rows.Scan(&metric, &layer, &hour, &hash, &points, &units, &sealed); err != nil {
|
||||
return "", fmt.Errorf("scan bucket: %w", err)
|
||||
}
|
||||
// Поля переменной длины идут с длиной впереди: разделитель, который
|
||||
// может встретиться ВНУТРИ поля, даёт одну строку для разных состояний,
|
||||
// а имя метрики и единицы приходят из тела доставки дословно. Ошибиться
|
||||
// здесь значит получить «состояние совпало» при разошедшемся состоянии —
|
||||
// то есть сломать молча ровно тот оракул, ради которого отпечаток и
|
||||
// заведён. Тот же приём в canon.HashAll и по той же причине.
|
||||
fmt.Fprintf(h, "%d:%s|%d:%s|%s|%s|%d|%d:%s|%t\n",
|
||||
len(metric), metric, len(layer), layer, hour, hash, points, len(units), units, sealed)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", fmt.Errorf("select buckets: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// Bucket читает объект по координатам. Нужен тестам и будущему Read API.
|
||||
func (s *Store) Bucket(ctx context.Context, metric, layer string, hour time.Time) (Bucket, error) {
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
|
||||
|
||||
@@ -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