store: при равной полноте точек побеждает пришедшая доставка
- байтовый порядок канонических форм остался тай-брейком только внутри одной доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил меньшее значение, из-за чего step_count терял род и verify:archive был красным - правило перестало быть коммутативным осознанно, поэтому порядок свёртки приведён к журнальному: проход воркера прекращается на отложенной доставке, а свёртка вне порядка журнала пишет WARN - заведены счётчики PointsHeld и PointsErased — удержание полнотой и единственное направление, в котором правило теряет содержание
This commit is contained in:
+144
-25
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user