Дозакрыты находки ревью по слиянию сущностей
- Правило покрытия получило второй разряд (условный, как у точек), запрет вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и ряд из null больше не затирают маршрут. Победитель внутри доставки стал функцией множества версий — общим помощником с точками, — а провенанс поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину к прежнему содержимому. - Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает на старте; текст ошибки разбора не несёт значений из тела. - Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты: безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя был квадратичен по числу присланных версий одного ключа.
This commit is contained in:
+80
-54
@@ -84,9 +84,23 @@ type MergeStats struct {
|
||||
// EntitiesHeld — приехавшие версии, отклонённые как теряющие содержание
|
||||
// сохранённой (включая несравнимые наборы). Это и есть плата за отказ
|
||||
// объединять поля: событие считается, а не предотвращается молча.
|
||||
//
|
||||
// На него опирается ЕДИНСТВЕННЫЙ контроль того, что правило покрытия не
|
||||
// стало слишком строгим: сходимость отпечатка этого не проверяет по
|
||||
// построению — живой приём и пересборка пользуются одним правилом и
|
||||
// одинаково сойдутся на одинаково удержанной версии. Поэтому счётчик
|
||||
// обязан считать ровно удержания и ничего сверх.
|
||||
EntitiesHeld int
|
||||
// HeldAt — координаты первых таких сущностей, для записи в лог.
|
||||
HeldAt []EntityRef
|
||||
// EntitiesDiverging — версии одного ключа, приехавшие в ОДНОМ теле с разным
|
||||
// содержанием. Событие другого рода: победитель ложится в витрину целиком,
|
||||
// терять нечего, лечится оно не тем же. Считается отдельно от удержаний,
|
||||
// иначе одно число отвечало бы на два вопроса — и число удержаний, по
|
||||
// которому судят о строгости правила, стало бы неотличимо от шума.
|
||||
EntitiesDiverging int
|
||||
// DivergingAt — координаты первых таких сущностей.
|
||||
DivergingAt []EntityRef
|
||||
}
|
||||
|
||||
// Collision — координаты объекта, где столкновение разрешилось перезаписью
|
||||
@@ -140,10 +154,22 @@ func (s *Store) Merge(ctx context.Context, in Incoming, from DeliveryRef) (Merge
|
||||
groups := groupByHour(in.Points)
|
||||
keys := sortedKeys(groups)
|
||||
|
||||
// Хеш и каноническая форма сущности считаются ОДИН раз на доставку, до
|
||||
// входа в транзакцию: канонизация материализует значение целиком, а
|
||||
// транзакция повторяется до пяти раз при занятости базы — внутри неё пик
|
||||
// кучи умножился бы на число попыток.
|
||||
// Каноническая форма сущности и её хеш считаются ОДИН раз на версию и здесь
|
||||
// — до входа в транзакцию. Транзакция открыта `immediate`, то есть блокирует
|
||||
// запись, и повторяется до пяти раз при занятости базы: канонизация внутри
|
||||
// неё умножала бы и пик кучи, и время удержания блокировки. Измерено: тело
|
||||
// 40 МиБ даёт 768 МиБ пика, 63 МиБ удерживают блокировку 5.019 с при
|
||||
// busy_timeout 5000, после чего конкурентный CreateDelivery исчерпывает
|
||||
// повторы.
|
||||
//
|
||||
// Пределов остаётся два, и оба названы вслух. Первый: разбор СОХРАНЁННОЙ
|
||||
// версии остаётся внутри транзакции — её содержимое читается оттуда же и
|
||||
// только когда хеш разошёлся; удержание блокировки пропорционально её
|
||||
// размеру. Второй: форма и множества ключей всех версий доставки
|
||||
// УДЕРЖИВАЮТСЯ в памяти до конца транзакции, то есть расход пропорционален
|
||||
// размеру доставки, а не самой большой её сущности. Про процессор здесь
|
||||
// стало лучше, про память — хуже, и закрыть оба может лишь предел на размер
|
||||
// сущности вместе с потоковым расчётом.
|
||||
workouts, err := prepareEntities(in.Workouts, from)
|
||||
if err != nil {
|
||||
return MergeStats{}, err
|
||||
@@ -152,18 +178,25 @@ func (s *Store) Merge(ctx context.Context, in Incoming, from DeliveryRef) (Merge
|
||||
if err != nil {
|
||||
return MergeStats{}, err
|
||||
}
|
||||
workouts, workoutsHeld, workoutsHeldAt := dedupeEntities(workouts)
|
||||
records, recordsHeld, recordsHeldAt := dedupeEntities(records)
|
||||
workouts, workoutsDiverging, workoutsDivergingAt, err := dedupeEntities(ctx, workouts)
|
||||
if err != nil {
|
||||
return MergeStats{}, err
|
||||
}
|
||||
records, recordsDiverging, recordsDivergingAt, err := dedupeEntities(ctx, records)
|
||||
if err != nil {
|
||||
return MergeStats{}, err
|
||||
}
|
||||
|
||||
var stats MergeStats
|
||||
err = s.inTx(ctx, func(tx *sql.Tx) error {
|
||||
// Счётчики обнуляются на каждой попытке: повтор транзакции начинает
|
||||
// слияние заново, и накопленное от прошлой попытки посчиталось бы дважды.
|
||||
stats = MergeStats{
|
||||
Workouts: len(in.Workouts),
|
||||
Records: len(in.Records),
|
||||
EntitiesHeld: workoutsHeld + recordsHeld,
|
||||
HeldAt: clipRefs(append(append([]EntityRef{}, workoutsHeldAt...), recordsHeldAt...)),
|
||||
Workouts: len(in.Workouts),
|
||||
Records: len(in.Records),
|
||||
EntitiesDiverging: workoutsDiverging + recordsDiverging,
|
||||
DivergingAt: clipRefs(append(append([]EntityRef{},
|
||||
workoutsDivergingAt...), recordsDivergingAt...)),
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
@@ -300,7 +333,10 @@ func mergeBucket(ctx context.Context, tx *sql.Tx, key bucketKey, group *pointGro
|
||||
return res, err
|
||||
}
|
||||
|
||||
merged, overwrites, incomparable := mergePoints(stored.Points, group.points)
|
||||
merged, overwrites, incomparable, err := mergePoints(ctx, stored.Points, group.points)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.overwrites = overwrites
|
||||
res.incomparable = incomparable
|
||||
// Считаем сохранённые точки, а не присланные: точные повторы внутри
|
||||
@@ -356,7 +392,7 @@ func mergeBucket(ctx context.Context, tx *sql.Tx, key bucketKey, group *pointGro
|
||||
// не заговорит.
|
||||
//
|
||||
// Точки из объекта не удаляются никогда.
|
||||
func mergePoints(stored, incoming []Point) (merged []Point, overwrites, incomparable int) {
|
||||
func mergePoints(ctx context.Context, stored, incoming []Point) (merged []Point, overwrites, incomparable int, err error) {
|
||||
type coord struct {
|
||||
start int64
|
||||
end int64
|
||||
@@ -404,7 +440,10 @@ func mergePoints(stored, incoming []Point) (merged []Point, overwrites, incompar
|
||||
out := make([]Point, 0, len(order))
|
||||
for _, c := range order {
|
||||
cands := byCoord[c]
|
||||
winner, unrelated := resolve(cands)
|
||||
winner, unrelated, err := resolve(ctx, cands)
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
// Перезаписей столько, сколько точек уступило: при двух кандидатах
|
||||
// одна, при трёх две. Так счёт остаётся сравнимым с прежним, где
|
||||
// столкновение считалось на каждую приехавшую точку.
|
||||
@@ -425,7 +464,7 @@ func mergePoints(stored, incoming []Point) (merged []Point, overwrites, incompar
|
||||
}
|
||||
return out[i].End.Before(out[j].End)
|
||||
})
|
||||
return out, overwrites, incomparable
|
||||
return out, overwrites, incomparable, nil
|
||||
}
|
||||
|
||||
// candidate — точка вместе с тем, что о ней нужно знать при выборе
|
||||
@@ -444,14 +483,11 @@ func newCandidate(p Point) candidate {
|
||||
|
||||
// resolve выбирает победителя среди кандидатов одной координаты.
|
||||
//
|
||||
// Победитель — функция МНОЖЕСТВА кандидатов, а не порядка их поступления.
|
||||
// Сперва отбрасываются те, кого превосходит по полноте кто-то другой
|
||||
// (полнота — частичный порядок, поэтому «непревзойдённые» определены
|
||||
// однозначно), затем среди оставшихся берётся минимум по каноническому
|
||||
// порядку — он тотальный, поэтому минимум единственен. Обе операции зависят
|
||||
// только от состава множества, поэтому пересборка журнала даёт то же
|
||||
// состояние, что живой приём, а повторная свёртка той же доставки не меняет
|
||||
// ничего.
|
||||
// Механизм общий с выбором версии сущности — pickBest: отбрасываем
|
||||
// превзойдённых по частичному порядку, среди оставшихся берём минимум по
|
||||
// тотальному. Отношения разные (полнота у точек, покрытие у сущностей), а
|
||||
// рассуждение одно, и второй его экземпляр однажды уже разошёлся со стандартом
|
||||
// нетранзитивностью.
|
||||
//
|
||||
// Победителем остаётся одна из пришедших точек ДОСЛОВНО: правило выбирает, а
|
||||
// не конструирует. Каноническая форма существует только в момент сравнения, и
|
||||
@@ -461,46 +497,36 @@ func newCandidate(p Point) candidate {
|
||||
// несравнимыми наборами содержательных полей. На живом потоке этого не
|
||||
// случилось ни разу (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
|
||||
}
|
||||
func resolve(ctx context.Context, cands []candidate) (Point, bool, error) {
|
||||
winner, maximal, err := pickBest(ctx, cands, pointDominates, pointLess)
|
||||
if err != nil {
|
||||
return Point{}, false, err
|
||||
}
|
||||
|
||||
// Несравнимость — не «осталось больше одного»: точки с одинаковыми
|
||||
// наборами полей и разными значениями тоже остаются обе, и это рядовой
|
||||
// тай-брейк. Считается только то, ради чего отложено объединение полей:
|
||||
// у каждой из двух есть содержательный ключ, которого нет у другой.
|
||||
return best.pt, hasIncomparablePair(maximal)
|
||||
return cands[winner].pt, hasIncomparablePair(cands, maximal), nil
|
||||
}
|
||||
|
||||
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 {
|
||||
// pointDominates — строгое превосходство по полноте. Relate возвращает
|
||||
// FullnessSuperset только когда a несёт всё, что b, и сверх того, поэтому
|
||||
// отношение уже строгое.
|
||||
func pointDominates(a, b candidate) bool {
|
||||
return a.fields.Relate(b.fields) == canon.FullnessSuperset
|
||||
}
|
||||
|
||||
// pointLess — тотальный порядок по канонической форме. Минимум единствен:
|
||||
// кандидаты с равной формой схлопываются ещё при сборе множества.
|
||||
func pointLess(a, b candidate) bool {
|
||||
return bytes.Compare(a.key, b.key) < 0
|
||||
}
|
||||
|
||||
func hasIncomparablePair(cands []candidate, maximal []int) bool {
|
||||
for i := range maximal {
|
||||
for j := i + 1; j < len(maximal); j++ {
|
||||
if cands[maximal[i]].fields.Relate(cands[maximal[j]].fields) == canon.FullnessIncomparable {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user