store: при равной полноте точек побеждает пришедшая доставка

- байтовый порядок канонических форм остался тай-брейком только внутри одной
  доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил
  меньшее значение, из-за чего step_count терял род и verify:archive был красным
- правило перестало быть коммутативным осознанно, поэтому порядок свёртки
  приведён к журнальному: проход воркера прекращается на отложенной доставке,
  а свёртка вне порядка журнала пишет WARN
- заведены счётчики PointsHeld и PointsErased — удержание полнотой и
  единственное направление, в котором правило теряет содержание
This commit is contained in:
av
2026-08-04 11:16:24 +03:00
parent ae607f1ceb
commit b278501a6e
44 changed files with 3780 additions and 172 deletions
+144 -25
View File
@@ -60,6 +60,38 @@ type MergeStats struct {
// сохранённых. Молчаливая замена недопустима: внутри точки единиц нет, и
// у ранних точек не остаётся ничего, по чему их единицы восстановимы.
UnitsConflicts int
// PointsHeld — координаты, где ПРИШЕДШАЯ точка проиграла сохранённой.
//
// Под тай-брейком «побеждает пришедшая» это возможно ровно тогда, когда
// сохранённая строго полнее, — то есть счётчик меряет единственное
// направление, в котором правило полноты спорит с журналом. Обратный случай
// (пришедшая строго полнее) сюда не входит: там полнота и журнал согласны.
//
// Отдельно от Overwrites, и это не педантизм: тот считает столкновение в обе
// стороны — на живом корпусе сработал бы около 80 000 раз, — и отличить
// «оставили пришедшую» от «выбросили пришедшую» по нему нельзя. То есть
// единственное событие, ради наблюдения за которым правило переписано,
// осталось бы невидимым. Тот же довод уже записан для EntitiesHeld.
PointsHeld int
// PointsHeldAt — координаты первых таких объектов, для записи в лог.
PointsHeldAt []Collision
// PointsErased — координаты, где победила ПРИШЕДШАЯ точка, а у проигравшей
// был содержательный ключ, которого у победительницы нет.
//
// Единственное направление, в котором новое правило способно потерять
// содержание. Разряд полноты там погашен по построению: значения общих
// содержательных ключей разошлись, и надмножество имён о полноте не говорит
// ничего. До смены тай-брейка исход решал байтовый порядок — то есть тот же
// исход случался по жребию и так же молча; разница в том, что теперь он
// детерминирован, а значит либо не случается вовсе, либо случается всегда.
// Замер на живом корпусе: 2 координаты из 80 129 спорных, обе — те же, что
// дают несравнимые наборы.
//
// Обратимо пересборкой, пока жив архив. Поэтому счётчик, а не запрет:
// объединение полей отложено тем же решением и по той же причине.
PointsErased int
// PointsErasedAt — координаты первых таких объектов, для записи в лог.
PointsErasedAt []Collision
// Incomparable — столкновения, где наборы полей несравнимы: у каждой точки
// есть содержательный ключ, которого нет у другой. Правило полноты тут
// бессильно, и вместо объединения полей — которого на живом потоке не
@@ -209,6 +241,8 @@ func (s *Store) Merge(ctx context.Context, in Incoming, from DeliveryRef) (Merge
stats.Stored += res.stored
stats.Overwrites += res.overwrites
stats.Incomparable += res.incomparable
stats.PointsHeld += res.held
stats.PointsErased += res.erased
if res.unchanged {
stats.Unchanged++
}
@@ -228,6 +262,12 @@ func (s *Store) Merge(ctx context.Context, in Incoming, from DeliveryRef) (Merge
if res.incomparable > 0 && len(stats.IncomparableAt) < maxCollisionsReported {
stats.IncomparableAt = append(stats.IncomparableAt, coord)
}
if res.held > 0 && len(stats.PointsHeldAt) < maxCollisionsReported {
stats.PointsHeldAt = append(stats.PointsHeldAt, coord)
}
if res.erased > 0 && len(stats.PointsErasedAt) < maxCollisionsReported {
stats.PointsErasedAt = append(stats.PointsErasedAt, coord)
}
}
now := Now()
@@ -315,6 +355,8 @@ func groupByHour(in []IncomingPoint) map[bucketKey]*pointGroup {
type mergeResult struct {
overwrites int
incomparable int
held int
erased int
stored int
unchanged bool
sealed bool
@@ -334,12 +376,14 @@ func mergeBucket(ctx context.Context, tx *sql.Tx, key bucketKey, group *pointGro
return res, err
}
merged, overwrites, incomparable, err := mergePoints(ctx, stored.Points, group.points)
merged, overwrites, incomparable, held, erased, err := mergePoints(ctx, stored.Points, group.points)
if err != nil {
return res, err
}
res.overwrites = overwrites
res.incomparable = incomparable
res.held = held
res.erased = erased
// Считаем сохранённые точки, а не присланные: точные повторы внутри
// доставки схлопываются, и счётчик присланных систематически завышал бы
// содержимое витрины — расхождение «прислали 1000, лежит 700» было бы
@@ -383,17 +427,28 @@ func mergeBucket(ctx context.Context, tx *sql.Tx, key bucketKey, group *pointGro
// 981 столкновение из 2 897 различается набором полей, и правило «последняя
// победила» стирало бы у сохранённой точки поля, которых новая не несёт.
//
// При равной полноте исход определяется порядком канонических форм, а не
// порядком доставок: у сохранённой точки нет провенанса, а четверть доставок
// несёт столкновения внутри себя, где время приёма общее. Свёртка по журналу
// обязана давать то же состояние, что приём в реальном времени.
// При равной полноте побеждает ПРИШЕДШАЯ точка, а не сохранённая: байтовый
// порядок канонических форм системно брал меньшее значение (1 847 из 1 912,
// находка 49), то есть хранил версию, которую источник уже пересчитал. Ценой
// этого час step_count терял род, и прогон живого архива краснел.
//
// Байтовый порядок остался вторым разрядом и решает столкновения ВНУТРИ одной
// доставки, где провенанс общий, а порядок элементов в JSON-массиве от HAE
// нестабилен.
//
// Плата названа вслух: правило перестало быть коммутативным, и содержимое
// витрины стало функцией ПОРЯДКА свёртки. Витрина остаётся свёрткой журнала
// ровно потому, что порядок свёртки приведён к порядку журнала — барьер на
// отложенной доставке в replay.Worker. Восстановление коммутативности «для
// чистоты» откатит починку молча.
//
// Второй счётчик — несравнимые наборы полей. Они и есть та часть правила,
// которую заменили наблюдением: объединять поля никто не будет, пока счётчик
// не заговорит.
// не заговорит. Третий — удержания: сколько раз сохранённая точка оказалась
// строго полнее пришедшей.
//
// Точки из объекта не удаляются никогда.
func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point, overwrites, incomparable int, err error) {
func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point, overwrites, incomparable, held, erased int, err error) {
type coord struct {
start int64
end int64
@@ -409,13 +464,13 @@ func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point,
// чего исход зависит от того, что уже лежало в объекте: одна и та же
// доставка, свёрнутая дважды, давала два разных состояния витрины
// поочерёдно. Это ломало главный инвариант — «состояние пересобираемо».
add := func(p Point) {
add := func(p Point, fromDelivery bool) {
c := coord{start: p.Start.UnixNano(), end: p.End.UnixNano()}
cands, seen := byCoord[c]
if !seen {
order = append(order, c)
}
cand := newCandidate(p)
cand := newCandidate(p, fromDelivery)
// Побайтовое равенство — только быстрый путь. Тем же самым считается
// совпадение КАНОНИЧЕСКИХ форм: канонизация и заведена потому, что
// байты нестабильны. Из 81952 повторно приехавших точек 67534
@@ -423,8 +478,18 @@ func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point,
// double. Считай мы по байтам, счётчик перезаписей давал бы тысячи
// ложных срабатываний на каждом глубоком проходе, и настоящий отказ
// правила слияния стал бы неотличим от нормы.
for _, q := range cands {
for i, q := range cands {
if bytes.Equal(q.key, cand.key) {
// Схлопывание ПОДНИМАЕТ флаг: доставка, приславшая то же
// содержимое, сказала о нём своё слово, и сохранённый
// кандидат теперь представляет её. Без этого он проигрывал бы
// соседу по доставке, которого обязан обойти по байтам, — то
// есть «внутри доставки решают байты» ломалось бы ровно
// тогда, когда доставка ничего не изменила. Байты остаются
// встреченные первыми: победитель хранится дословно, а разница
// при совпавшей канонической форме ненаблюдаема — хеш объекта
// считается по канонической форме.
cands[i].incoming = q.incoming || fromDelivery
return
}
}
@@ -432,18 +497,18 @@ func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point,
}
for _, p := range stored {
add(p)
add(p, false)
}
for _, p := range incoming {
add(p)
add(p, true)
}
out := make([]Point, 0, len(order))
for _, c := range order {
cands := byCoord[c]
winner, unrelated, err := resolve(ctx, cands)
winner, unrelated, keptStored, lostKey, err := resolve(ctx, cands)
if err != nil {
return nil, 0, 0, err
return nil, 0, 0, 0, 0, err
}
// Перезаписей столько, сколько точек уступило: при двух кандидатах
// одна, при трёх две. Так счёт остаётся сравнимым с прежним, где
@@ -452,6 +517,12 @@ func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point,
if unrelated {
incomparable++
}
if keptStored {
held++
}
if lostKey {
erased++
}
out = append(out, winner)
}
@@ -465,7 +536,7 @@ func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point,
}
return out[i].End.Before(out[j].End)
})
return out, overwrites, incomparable, nil
return out, overwrites, incomparable, held, erased, nil
}
// candidate — точка вместе с тем, что о ней нужно знать при выборе
@@ -476,10 +547,19 @@ type candidate struct {
pt Point
key []byte // каноническая форма: она же ключ дедупликации и порядок
fields canon.Fields
// incoming — точка приехала РАЗБИРАЕМОЙ доставкой, а не лежала в объекте.
//
// Это второй и старший разряд тотального порядка: при равной полноте
// побеждает пришедшая. Провенанса у точки нет и не будет (колонка означала
// бы смену формата содержимого объекта), поэтому «позже по журналу»
// выражается единственным доступным способом — происхождением кандидата. И
// ровно поэтому порядок свёртки обязан равняться порядку журнала: правило
// перестало быть коммутативным осознанно, см. openspec storage.
incoming bool
}
func newCandidate(p Point) candidate {
return candidate{pt: p, key: canon.SortKey(p.Raw), fields: canon.Analyze(p.Raw)}
func newCandidate(p Point, incoming bool) candidate {
return candidate{pt: p, key: canon.SortKey(p.Raw), fields: canon.Analyze(p.Raw), incoming: incoming}
}
// resolve выбирает победителя среди кандидатов одной координаты.
@@ -495,20 +575,49 @@ func newCandidate(p Point) candidate {
// вернуть её значило бы сохранить округлённое число вместо присланного.
//
// Второй возврат — остались ли непревзойдёнными несколько точек с
// несравнимыми наборами содержательных полей. На живом потоке этого не
// случилось ни разу (0 из 2 897 столкновений), поэтому объединение полей не
// реализовано: вместо него счётчик, который скажет, если событие наступит.
func resolve(ctx context.Context, cands []candidate) (Point, bool, error) {
// несравнимыми наборами содержательных полей. На живом потоке событие
// единично (2 пары на 155 доставок), поэтому объединение полей не
// реализовано: вместо него счётчик, который скажет, если оно станет массовым.
//
// Третий возврат — победила ли СОХРАНЁННАЯ точка при наличии пришедших. Под
// новым тай-брейком это возможно ровно тогда, когда сохранённая строго полнее,
// то есть счётчик меряет единственное направление, в котором полнота спорит с
// журналом. Отдельно от Overwrites: тот считает столкновение в обе стороны и
// «оставили пришедшую» от «выбросили пришедшую» не отличает.
//
// Четвёртый — обратное направление, и оно опаснее: победила пришедшая, а у
// проигравшей был содержательный ключ, которого у победительницы нет. Разряд
// полноты в этом случае погашен по построению — значения общих содержательных
// ключей разошлись, и Relate объявляет пару равнополной, — так что защитить
// содержание некому. До смены тай-брейка исход здесь решал байтовый порядок,
// то есть жребий; теперь он детерминирован в пользу пришедшей. Событие редкое
// (на живом корпусе 2 координаты из 80 129 спорных, обе несравнимые), но это
// единственное направление, в котором правило теряет содержание, и оно обязано
// быть видно — иначе «ничего не теряем молча» держится на слове.
func resolve(ctx context.Context, cands []candidate) (Point, bool, bool, bool, error) {
winner, maximal, err := pickBest(ctx, cands, pointDominates, pointLess)
if err != nil {
return Point{}, false, err
return Point{}, false, false, false, err
}
held, erased := false, false
for i, c := range cands {
if i == winner {
continue
}
if !cands[winner].incoming && c.incoming {
held = true
}
if cands[winner].incoming && c.fields.CarriesKeyAbsentIn(cands[winner].fields) {
erased = true
}
}
// Несравнимость — не «осталось больше одного»: точки с одинаковыми
// наборами полей и разными значениями тоже остаются обе, и это рядовой
// тай-брейк. Считается только то, ради чего отложено объединение полей:
// у каждой из двух есть содержательный ключ, которого нет у другой.
return cands[winner].pt, hasIncomparablePair(cands, maximal), nil
return cands[winner].pt, hasIncomparablePair(cands, maximal), held, erased, nil
}
// pointDominates — строгое превосходство по полноте. Relate возвращает
@@ -518,9 +627,19 @@ func pointDominates(a, b candidate) bool {
return a.fields.Relate(b.fields) == canon.FullnessSuperset
}
// pointLess — тотальный порядок по канонической форме. Минимум единствен:
// кандидаты с равной формой схлопываются ещё при сборе множества.
// pointLess — тотальный порядок: сперва происхождение, затем каноническая
// форма. Минимум единствен: кандидаты с равной формой схлопываются ещё при
// сборе множества, значит ключи различны и лексикографическая пара
// `(не incoming, ключ)` трихотомична.
//
// Пришедшая идёт РАНЬШЕ сохранённой, потому что pickBest берёт минимум. Это и
// есть тай-брейк «побеждает пришедшая»: байтовый порядок остаётся вторым
// разрядом и работает там, где происхождение одинаково, — то есть внутри одной
// доставки, где провенанс общий и различать нечем.
func pointLess(a, b candidate) bool {
if a.incoming != b.incoming {
return a.incoming
}
return bytes.Compare(a.key, b.key) < 0
}
+331 -30
View File
@@ -351,10 +351,16 @@ func TestMergeРавноеСодержаниеНеТеряетПоля(t *testin
st := open(t)
ctx := context.Background()
// Значение общего содержательного ключа СОВПАДАЕТ — таково условие второго
// разряда в спеке: он сравнивает все ключи только там, где содержание
// сошлось. Прежняя редакция теста давала `qty` разные, второй разряд при
// этом не запускался вовсе, и точка с `Min`/`Max` выигрывала по случайности
// байтового порядка (`M` сортируется раньше `d`). Зелёный тест проверял не
// то требование, которое называл.
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}`)
`{"date":"d","qty":10}`)
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{wide}}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err)
@@ -372,6 +378,86 @@ func TestMergeРавноеСодержаниеНеТеряетПоля(t *testin
}
}
// Обратная сторона того же разряда, и она названа ценой изменения: когда
// значения общих содержательных ключей РАЗОШЛИСЬ, полнота ответа не даёт по
// построению («надмножество имён о полноте не говорит ничего»), и пришедшая
// точка побеждает вместе с потерей пустых ключей сохранённой. Прежде исход
// здесь решался байтовым порядком, то есть был случайным.
func TestMergeРазошедшиесяЗначенияНеУдерживаютсяПустымиКлючами(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.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{wide}}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err)
}
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{narrow}}, store.DeliveryRef{ID: "d2"})
if 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(narrow.Raw) {
t.Errorf("в витрине %s, ожидалась пришедшая точка %s", got, narrow.Raw)
}
if stats.Overwrites != 1 {
t.Errorf("перезаписей %d, ожидалась 1", stats.Overwrites)
}
if stats.PointsHeld != 0 {
t.Errorf("удержаний %d: пришедшая победила, удерживать было нечего", stats.PointsHeld)
}
}
// Полнота сильнее происхождения: пришедшая точка, теряющая содержательный ключ
// сохранённой при совпадающих значениях, проигрывает — и это единственный
// случай, когда пришедшая вообще проигрывает. Счётчик удержаний считает ровно
// его и отдельно от перезаписей.
func TestMergeПолнотаСильнееПроисхождения(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
rich := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"Avg":72,"Min":60,"Max":90,"context":"rest"}`)
poor := point(t, "heart_rate", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"Avg":72,"Min":60,"Max":90}`)
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{rich}}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err)
}
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{poor}}, store.DeliveryRef{ID: "d2"})
if 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(rich.Raw) {
t.Errorf("в витрине %s: пришедшая бедная точка стёрла содержательный ключ", got)
}
if stats.PointsHeld != 1 {
t.Errorf("удержаний %d, ожидалось 1", stats.PointsHeld)
}
if len(stats.PointsHeldAt) != 1 {
t.Fatalf("координат удержания %d, ожидалась 1", len(stats.PointsHeldAt))
}
if stats.PointsHeldAt[0].Metric != "heart_rate" || stats.PointsHeldAt[0].Layer != "minute" {
t.Errorf("координаты удержания %+v: не те", stats.PointsHeldAt[0])
}
}
// Несравнимые наборы полей на живом потоке не встретились ни разу (0 из 2 897
// столкновений), поэтому объединение полей не реализовано. Взамен — наблюдение:
// счётчик и координаты объекта, по которым событие можно будет разобрать.
@@ -480,10 +566,10 @@ func TestMergeСменаИсточникаНеСоздаётВторуюТочк
}
}
// Исход столкновения точек равной полноты обязан зависеть только от значений:
// свёртка по журналу должна давать то же состояние, что приём в реальном
// времени, а внутри одной доставки время приёма общее.
func TestMergeРавнаяПолнотаРазрешаетсяДетерминированно(t *testing.T) {
// Внутри ОДНОЙ доставки провенанс общий, различать точки нечем, а порядок
// элементов в JSON-массиве от HAE нестабилен — поэтому здесь исход обязан
// остаться функцией множества и решаться порядком канонических форм.
func TestMergeРавнаяПолнотаВнутриДоставкиНеЗависитОтПорядка(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -491,12 +577,10 @@ func TestMergeРавнаяПолнотаРазрешаетсяДетермини
a := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)
b := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":2}`)
winner := func(order []store.IncomingPoint) string {
winner := func(batch []store.IncomingPoint) string {
st := open(t)
for _, p := range order {
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p}}, store.DeliveryRef{ID: "d"}); err != nil {
t.Fatalf("слияние: %v", err)
}
if _, err := st.Merge(ctx, store.Incoming{Points: batch}, store.DeliveryRef{ID: "d"}); err != nil {
t.Fatalf("слияние: %v", err)
}
got, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
if err != nil {
@@ -508,7 +592,119 @@ func TestMergeРавнаяПолнотаРазрешаетсяДетермини
forward := winner([]store.IncomingPoint{a, b})
backward := winner([]store.IncomingPoint{b, a})
if forward != backward {
t.Errorf("исход зависит от порядка доставок: %s против %s", forward, backward)
t.Errorf("исход внутри доставки зависит от порядка элементов: %s против %s", forward, backward)
}
if forward != string(a.Raw) {
t.Errorf("внутри доставки победила %s, ожидался минимум канонической формы %s", forward, a.Raw)
}
}
// А МЕЖДУ доставками побеждает пришедшая, и это осознанная потеря
// коммутативности: байтовый порядок системно хранил меньшее значение и
// стоил `step_count` его рода. Витрина остаётся свёрткой журнала потому, что
// порядок свёртки приведён к порядку журнала, а не потому, что правило
// безразлично к порядку.
func TestMergeРавнаяПолнотаМеждуДоставкамиБерётПришедшую(t *testing.T) {
t.Parallel()
ctx := context.Background()
a := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)
b := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":2}`)
last := func(order []store.IncomingPoint) string {
st := open(t)
for i, p := range order {
ref := store.DeliveryRef{ID: fmt.Sprintf("d%d", i)}
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{p}}, ref); err != nil {
t.Fatalf("слияние: %v", err)
}
}
got, err := st.Bucket(ctx, "step_count", "minute", ts(t, "2025-06-05T10:00:00Z"))
if err != nil {
t.Fatalf("чтение: %v", err)
}
return string(got.Points[0].Raw)
}
if got := last([]store.IncomingPoint{a, b}); got != string(b.Raw) {
t.Errorf("после доставок a,b в витрине %s, ожидалась пришедшая последней %s", got, b.Raw)
}
// Обратный порядок даёт обратный исход — ровно то свойство, ради которого
// заведён барьер журнального порядка в воркере.
if got := last([]store.IncomingPoint{b, a}); got != string(a.Raw) {
t.Errorf("после доставок b,a в витрине %s, ожидалась пришедшая последней %s", got, a.Raw)
}
}
// Повторная присылка того же содержимого другими байтами ничего не переписывает:
// схлопывание поднимает флаг происхождения, но байты в витрине остаются
// сохранённые, и хеш-детектор гасит запись.
func TestMergeПовторКаноническиТойЖеТочкиНеПереписывает(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
first := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"date":"d","qty":10}`)
// Тот же смысл, другие байты: порядок ключей у HAE нестабилен.
again := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z",
`{"qty":10,"date":"d"}`)
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{first}}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err)
}
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{again}}, store.DeliveryRef{ID: "d2"})
if err != nil {
t.Fatalf("второе слияние: %v", err)
}
if stats.Overwrites != 0 {
t.Errorf("перезаписей %d: дребезг порядка ключей посчитан столкновением", stats.Overwrites)
}
if stats.Unchanged != 1 {
t.Errorf("неизменившихся объектов %d, ожидался 1: объект переписан зря", stats.Unchanged)
}
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(first.Raw) {
t.Errorf("в витрине %s, ожидались сохранённые байты %s", got, first.Raw)
}
}
// Точка, канонически совпавшая с сохранённой, представляет доставку наравне с
// остальными её точками: иначе она проигрывала бы соседу по телу, которого
// обязана обойти по байтам, — то есть «внутри доставки решают байты» ломалось
// бы ровно тогда, когда доставка ничего не изменила.
func TestMergeСхлопываниеПоднимаетПроисхождение(t *testing.T) {
t.Parallel()
ctx := context.Background()
stored := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)
same := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)
other := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":2}`)
st := open(t)
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{stored}}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err)
}
// Вторая доставка несёт обе формы: совпавшую с сохранённой и другую.
// Победить обязана меньшая каноническая форма, то есть совпавшая, — а не
// вторая только потому, что у первой не поднялся флаг.
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{same, other}}, store.DeliveryRef{ID: "d2"}); 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(stored.Raw) {
t.Errorf("в витрине %s, ожидался минимум канонической формы доставки %s", got, stored.Raw)
}
}
@@ -755,24 +951,57 @@ func TestMergeИсходНеЗависитОтПерестановки(t *testin
ctx := context.Background()
in := cycleTriple(t)
// Все шесть перестановок тройки, и вдобавок разбиение на разные доставки:
// в живом приёме точки приходят порознь, в пересборке — вместе.
// Все шесть перестановок тройки ВНУТРИ одной доставки. Разбиение на разные
// доставки сюда больше не входит намеренно: между доставками правило
// перестало быть коммутативным — при равной полноте побеждает пришедшая, —
// и требовать здесь независимости от порядка значило бы требовать отката
// починки. Что осталось верным между доставками, проверяют
// `TestMergeРавнаяПолнотаМеждуДоставкамиБерётПришедшую` (исход = последняя
// доставка) и прогон сходимости живого пути с пересборкой в
// `internal/replay`.
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 {
winner := func(order []int) string {
st := open(t)
if split {
for _, i := range order {
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{in[i]}}, store.DeliveryRef{ID: "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.Merge(ctx, store.Incoming{Points: batch}, store.DeliveryRef{ID: "d"}); err != nil {
batch := make([]store.IncomingPoint, 0, len(order))
for _, i := range order {
batch = append(batch, in[i])
}
if _, err := st.Merge(ctx, store.Incoming{Points: batch}, store.DeliveryRef{ID: "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])
for _, p := range perms {
if got := winner(p); got != want {
t.Errorf("перестановка %v дала %s, ожидалось %s", p, got, want)
}
}
}
// Та же тройка, разложенная по РАЗНЫМ доставкам: исход теперь зависит от
// порядка, и это не дефект, а объявленная цена. Проверяется другое и не менее
// важное — что зависимость ДЕТЕРМИНИРОВАНА: один и тот же порядок журнала
// обязан давать один и тот же исход, иначе витрина перестала бы быть свёрткой
// журнала вовсе, а не только функцией его порядка.
func TestMergeПорядокДоставокДаётВоспроизводимыйИсход(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) string {
st := open(t)
for n, i := range order {
ref := store.DeliveryRef{ID: fmt.Sprintf("d%d", n)}
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{in[i]}}, ref); err != nil {
t.Fatalf("слияние: %v", err)
}
}
@@ -783,11 +1012,11 @@ func TestMergeИсходНеЗависитОтПерестановки(t *testin
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)
first := winner(p)
for pass := 2; pass <= 3; pass++ {
if got := winner(p); got != first {
t.Errorf("порядок %v дал %s, а прежде %s: исход невоспроизводим", p, got, first)
}
}
}
@@ -857,3 +1086,75 @@ func TestMergeИмяМетрикиВКоординатеОбрезано(t *test
t.Errorf("имя метрики в координате %d байт — оно уедет в лог как есть", got)
}
}
// Единственное направление, в котором новое правило теряет содержание:
// значения общих содержательных ключей разошлись, разряд полноты погас, и
// победила пришедшая точка, у которой содержательного ключа нет.
//
// До смены тай-брейка исход здесь решал байтовый порядок — то есть та же
// потеря случалась по жребию и так же молча. Разница в том, что теперь она
// детерминирована и СЧИТАЕТСЯ.
func TestMergeПришедшаяУнесшаяСодержаниеСчитаетсяОтдельно(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
// `rem` — содержательный ключ сохранённой; у пришедшей его нет, а значение
// общего ключа `asleep` разошлось, поэтому Relate объявляет пару равнополной.
stored := point(t, "sleep_analysis_summary", "day", "2025-06-05T00:00:00Z", "2025-06-05T00:00:00Z",
`{"date":"d","asleep":7.5,"rem":1.2}`)
arriving := point(t, "sleep_analysis_summary", "day", "2025-06-05T00:00:00Z", "2025-06-05T00:00:00Z",
`{"date":"d","asleep":7.4}`)
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{stored}}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err)
}
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{arriving}}, store.DeliveryRef{ID: "d2"})
if err != nil {
t.Fatalf("второе слияние: %v", err)
}
b, err := st.Bucket(ctx, "sleep_analysis_summary", "day", ts(t, "2025-06-05T00:00:00Z"))
if err != nil {
t.Fatalf("чтение: %v", err)
}
if got := string(b.Points[0].Raw); got != string(arriving.Raw) {
t.Fatalf("в витрине %s, ожидалась пришедшая точка %s", got, arriving.Raw)
}
if stats.PointsErased != 1 {
t.Errorf("потерь содержания %d, ожидалась 1: направление, в котором правило теряет, осталось невидимым", stats.PointsErased)
}
if len(stats.PointsErasedAt) != 1 {
t.Errorf("координат потери %d, ожидалась 1", len(stats.PointsErasedAt))
}
if stats.PointsHeld != 0 {
t.Errorf("удержаний %d: победила пришедшая, удерживать было нечего", stats.PointsHeld)
}
}
// Обратное: пришедшая победила, ничего не унеся, — счётчик молчит. Без этой
// пары счётчик потерь был бы неотличим от счётчика перезаписей.
func TestMergeПришедшаяБезПотериСодержанияНеСчитается(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
first := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"date":"d","qty":1}`)
second := point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"date":"d","qty":2}`)
if _, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{first}}, store.DeliveryRef{ID: "d1"}); err != nil {
t.Fatalf("первое слияние: %v", err)
}
stats, err := st.Merge(ctx, store.Incoming{Points: []store.IncomingPoint{second}}, store.DeliveryRef{ID: "d2"})
if err != nil {
t.Fatalf("второе слияние: %v", err)
}
if stats.PointsErased != 0 {
t.Errorf("потерь содержания %d, ожидалось 0: наборы ключей совпадают, терять нечего", stats.PointsErased)
}
if stats.Overwrites != 1 {
t.Errorf("перезаписей %d, ожидалась 1", stats.Overwrites)
}
}
+32
View File
@@ -236,6 +236,38 @@ func (s *Store) PendingDeliveries(ctx context.Context, after PendingDelivery, li
return out, nil
}
// ParsedAfter отвечает, есть ли в журнале доставка ПОЗЖЕ указанной позиции,
// уже записавшая свой разбор в витрину.
//
// Нужен ровно одному наблюдению: свёртка вне порядка журнала. Правило слияния
// точек разрешает равную полноту в пользу пришедшей доставки, то есть исход
// есть функция порядка свёртки; доставка, свёрнутая после своей преемницы,
// возвращает координату к версии, которую источник уже пересчитал, и живая
// витрина расходится с пересборкой. Барьер воркера этого не ловит: отставшей
// доставки в момент прохода просто не существует — строка учёта становится
// видимой только после записи тела.
//
// `failed` в предикат НЕ входит: такая доставка в витрину ничего не записала,
// перестановка относительно неё содержимого не разводит, а исход этот штатный
// (невыводимый слой), и его учёт превратил бы WARN в шум.
//
// Это страж окна, а не постоянная часть свёртки: закрыв порядок на самом
// приёме, метод и его вызов сносят вместе с окном.
func (s *Store) ParsedAfter(ctx context.Context, at time.Time, id string) (bool, error) {
// Кортежное сравнение, а не через OR: развёрнутая форма даёт SCAN по
// индексу вместо SEARCH — тот же приём, что в PendingDeliveries.
const q = `
SELECT EXISTS(
SELECT 1 FROM delivery
WHERE parse_status IN (?, ?) AND (received_at, id) > (?, ?))`
var found bool
if err := s.db.GetContext(ctx, &found, q, ParseDone, ParsePartial, FormatTime(at), id); err != nil {
return false, fmt.Errorf("select parsed after: %w", err)
}
return found, nil
}
// CountPendingDeliveries возвращает размер задолженности — сколько доставок
// ждут свёртки. Нужен ровно одной строке лога при старте: сколько сервис должен
// разобрать, прежде чем витрина станет полной.
+19 -15
View File
@@ -197,12 +197,16 @@ func (v *entityVersion) sortKey() []byte {
// 3. содержание равно → версия из более поздней доставки ЖУРНАЛА
// 4. наборы несравнимы → сохранённая
//
// Пункт 3 — не «побеждает приехавшая». Приехавшая есть функция порядка
// СВЁРТКИ, а он порядку журнала не равен: воркер сворачивает в порядке журнала
// только среди видимых ему доставок. Доставка с более ранней меткой, свёрнутая
// позже, вернула бы витрину к недосчитанной версии, и пересборка разошлась бы
// с живым приёмом молча — в содержимом тренировки, где это не видно ничем,
// кроме отпечатка.
// Пункт 3 — не «побеждает приехавшая», и различие здесь в СИЛЕ гарантии, а не
// в намерении. Порядок свёртки приведён к порядку журнала (барьер на
// отложенной доставке в replay.Worker), но равенство неполное: строка учёта
// становится видимой только после записи тела, и при конкурентном приёме
// доставка с более ранней меткой сворачивается позже. Хранимая позиция журнала
// это окно закрывает целиком, происхождение кандидата — нет. У точек колонки
// провенанса нет и заводить её дорого, поэтому там взято происхождение вместе
// с обеспеченным порядком; здесь колонка есть, и терять из-за неё гарантию
// незачем. Критерий выбора записан в architecture.md, «Разрешение
// столкновений».
//
// Равные позиции означают две версии одного ключа внутри ОДНОЙ доставки; там
// решает минимум канонической формы, потому что порядок элементов в
@@ -220,15 +224,15 @@ func pickEntity(stored, incoming *entityVersion) (takeIncoming, lost bool) {
// версий остаётся сохранённая: правило называется «не теряет
// содержания», и приехавшая его теряет.
//
// ЗДЕСЬ И ТОЛЬКО ЗДЕСЬ исход зависит от порядка свёртки, а не от
// журнала: в витрине лежит победитель прошлых слияний, а не все
// кандидаты истории, и «сохранённая выигрывает» означает разный итог
// при разном порядке. Порядок свёртки журналу не равен — доставка,
// получившая ErrBusy, остаётся `pending` и сворачивается следующим
// проходом, — так что живой приём и пересборка на несравнимых версиях
// законно расходятся. Это единственная точка, где витрина не является
// функцией множества доставок; она названа вслух в architecture.md, и
// счётчик удержаний ниже — единственное, что о ней сообщает.
// Исход здесь зависит от порядка свёртки, а не от журнала: в витрине
// лежит победитель прошлых слияний, а не все кандидаты истории, и
// «сохранённая выигрывает» означает разный итог при разном порядке.
// Окно этого расхождения СУЖЕНО: доставка, получившая ErrBusy, больше
// не перегоняется своими преемницами — проход воркера прекращается на
// ней. Осталось окно конкурентного приёма, где строка учёта становится
// видимой позже метки; там живой приём и пересборка на несравнимых
// версиях законно расходятся, и воркер об этом пишет WARN. Счётчик
// удержаний ниже — второе, что о расхождении сообщает.
return false, true
default:
return laterInJournal(stored, incoming), false
+8 -4
View File
@@ -819,12 +819,16 @@ func TestMergeПустойКлючНеЗапираетДосчёт(t *testing.T)
}
}
// Единственная точка, где витрина НЕ является функцией множества доставок, —
// Точка, где витрина сущностей НЕ является функцией множества доставок, —
// несравнимые версии. Тест не чинит это, а закрепляет: в витрине лежит
// победитель прошлых слияний, а не все кандидаты истории, поэтому «сохранённая
// выигрывает» даёт разный итог при разном порядке. Порядок свёртки журналу не
// равен: доставка, получившая ErrBusy, остаётся `pending` и сворачивается
// следующим проходом.
// выигрывает» даёт разный итог при разном порядке.
//
// Окно этого расхождения СУЖЕНО: доставка, получившая ErrBusy, больше не
// перегоняется своими преемницами — проход воркера прекращается на ней.
// Осталось окно конкурентного приёма, где строка учёта становится видимой позже
// метки. У точек единственной такой точки давно нет: там от порядка журнала
// зависит весь тай-брейк равной полноты, и это объявлено вслух.
//
// Предел назван в pickEntity и в architecture.md; наблюдается счётчиком
// удержаний. Если он когда-нибудь будет закрыт, красный тест напомнит, что
+60
View File
@@ -151,3 +151,63 @@ func seedPending(t *testing.T, st *store.Store, id string, at time.Time) {
t.Fatalf("запись доставки %q: %v", id, err)
}
}
// Свёртка вне порядка журнала обнаруживается запросом «есть ли позже меня уже
// разобранная доставка». Это единственное наблюдение, по которому расхождение
// живой витрины с пересборкой видно до сверки отпечатков.
func TestParsedAfterВидитТолькоЗаписавшихВВитрину(t *testing.T) {
t.Parallel()
st := open(t)
ctx := context.Background()
at := ts(t, "2026-08-01T12:00:00Z")
early := at
late := at.Add(time.Second)
seedPending(t, st, "d-early", early)
seedPending(t, st, "d-late", late)
// Пока преемница в очереди, расхождения нет.
found, err := st.ParsedAfter(ctx, early, "d-early")
if err != nil {
t.Fatalf("ParsedAfter: %v", err)
}
if found {
t.Error("неразобранная преемница засчитана расхождением")
}
// `failed` преемницей не считается: в витрину она ничего не записала, и
// перестановка относительно неё содержимого не разводит. Исход этот
// штатный — невыводимый слой, — и его учёт превратил бы WARN в шум.
if err := st.FinishParse(ctx, "d-late", store.ParseOutcome{Status: store.ParseFailed}); err != nil {
t.Fatalf("FinishParse: %v", err)
}
found, err = st.ParsedAfter(ctx, early, "d-early")
if err != nil {
t.Fatalf("ParsedAfter: %v", err)
}
if found {
t.Error("отказавшая преемница засчитана расхождением")
}
// А разобранная — считается.
if err := st.FinishParse(ctx, "d-late", store.ParseOutcome{Status: store.ParseDone}); err != nil {
t.Fatalf("FinishParse: %v", err)
}
found, err = st.ParsedAfter(ctx, early, "d-early")
if err != nil {
t.Fatalf("ParsedAfter: %v", err)
}
if !found {
t.Error("разобранная преемница не замечена: свёртка вне порядка журнала осталась бы молчащей")
}
// Сама преемница расхождением не является: предикат строго «позже».
found, err = st.ParsedAfter(ctx, late, "d-late")
if err != nil {
t.Fatalf("ParsedAfter: %v", err)
}
if found {
t.Error("доставка засчитала расхождением саму себя")
}
}
+6 -4
View File
@@ -13,10 +13,12 @@ import "context"
// сущностях, где ту же ошибку повторили молча.
//
// Отсюда и общий помощник вместо второй рукописной копии: механизм один,
// отношения разные. `architecture.md` уже обещает смену тай-брейка точек, когда
// род метрики будет измерен, — то есть правка одного экземпляра при живом
// втором запланирована заранее, и расхождение правил слияния ломает детерминизм
// свёртки молча.
// отношения разные. Правка одного экземпляра при живом втором уже случилась:
// тай-брейк точек сменён с байтового порядка на «побеждает пришедшая», а
// тай-брейк сущностей остался позицией журнала — потому что у сущности есть
// колонка провенанса, а у точки нет. Механизм при этом не разъехался ровно
// благодаря общему помощнику; критерий выбора между двумя правилами записан в
// `architecture.md`, раздел «Разрешение столкновений».
//
// dominates(a, b) обязан быть СТРОГИМ превосходством: a не хуже b и b не не
// хуже a. Иначе взаимно покрывающие друг друга кандидаты выбьют друг друга, и