Дозакрыты находки ревью по слиянию сущностей

- Правило покрытия получило второй разряд (условный, как у точек), запрет
  вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и
  ряд из null больше не затирают маршрут. Победитель внутри доставки стал
  функцией множества версий — общим помощником с точками, — а провенанс
  поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину
  к прежнему содержимому.
- Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной
  записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма
  считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает
  на старте; текст ошибки разбора не несёт значений из тела.
- Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты:
  безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя
  был квадратичен по числу присланных версий одного ключа.
This commit is contained in:
av
2026-08-02 16:38:18 +03:00
parent 51a5272c96
commit 8331328134
52 changed files with 4921 additions and 481 deletions
+63 -12
View File
@@ -117,7 +117,7 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
}
stats = Stats{}
err = fmt.Errorf("%w: %v", ErrPanicked, r) //nolint:errorlint // причину раскрываем текстом, sentinel — для ветвления
s.fail(ctx, deliveryID, err, nil)
s.fail(ctx, deliveryID, err, parseResidue{})
}()
d, err := s.store.DeliveryForParse(ctx, deliveryID)
@@ -131,7 +131,7 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
body, err := s.readBody(d.RawPath)
if err != nil {
s.fail(ctx, deliveryID, err, nil)
s.fail(ctx, deliveryID, err, parseResidue{})
return stats, err
}
@@ -149,7 +149,11 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
// Список непокрытых секций переживает отказ: доставка, у которой не
// определился слой, обязана остаться записью о том, что в теле есть
// невосстановимая секция.
s.fail(ctx, deliveryID, err, parsed.Uncovered)
//
// А вот число пропущенных сущностей — НЕ переживает: разбор, вернувший
// ошибку, отдаёт нулевые счётчики по построению, а не по измерению, и
// записать этот ноль значило бы объявить доставку проверенной.
s.fail(ctx, deliveryID, err, residueOf(parsed))
return stats, err
}
@@ -171,7 +175,12 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
ReceivedAt: d.ReceivedAt,
})
if err != nil {
s.fail(ctx, deliveryID, err, parsed.Uncovered)
// Здесь разбор досчитал: отказало слияние. Значит счётчик пропусков
// измерен и обязан дойти до учёта — в отличие от ветки выше.
s.fail(ctx, deliveryID, err, parseResidue{
uncovered: parsed.Uncovered,
skipped: skippedEntities(parsed),
})
return stats, err
}
stats.MergeStats = merge
@@ -184,10 +193,11 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
status = store.ParsePartial
}
out := store.ParseOutcome{
Status: status,
Points: int64(stats.Points),
Layer: stats.Layer,
Uncovered: parsed.Uncovered,
Status: status,
Points: int64(stats.Points),
Layer: stats.Layer,
Uncovered: parsed.Uncovered,
SkippedEntities: skippedEntities(parsed),
}
if err := s.finish(ctx, deliveryID, out); err != nil {
s.log.ErrorContext(ctx, "delivery fold failed", "error", err, "delivery_id", deliveryID)
@@ -237,6 +247,7 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
"records", st.Records,
"records_written", st.RecordsWritten,
"entities_held", st.EntitiesHeld,
"entities_diverging", st.EntitiesDiverging,
"skipped_entities", skippedEntities,
"layer", st.Layer,
"layer_mismatch", st.LayerMismatch,
@@ -255,6 +266,9 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
if len(st.HeldAt) > 0 {
attrs = append(attrs, "held_at", formatEntityRefs(st.HeldAt))
}
if len(st.DivergingAt) > 0 {
attrs = append(attrs, "diverging_at", formatEntityRefs(st.DivergingAt))
}
// Доставка, у которой отброшены ВСЕ точки, — это сломавшийся формат, а не
// штатная работа. Без этого условия смена формата метки выглядела бы как
@@ -270,6 +284,14 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
// за отказ объединять поля: событие обязано быть видно, потому что на
// живом потоке оно не наступало ни разу и правило держится на этом.
s.log.WarnContext(ctx, "delivery folded, poorer entity version held", attrs...)
case st.EntitiesDiverging > 0:
// Событие другого рода и с другим лечением: в одном теле приехали
// версии одного ключа с разным содержанием. Победитель лёг в витрину
// целиком, терять нечего — но корпус такого не производил, и молчать
// об этом нельзя. Отдельной ветвью, а не общей с удержанием: сообщение
// «удержана обеднённая версия» отправляло бы владельца искать то, чего
// не случилось.
s.log.WarnContext(ctx, "delivery folded, entity versions diverge in one body", attrs...)
case allEntitiesSkipped:
s.log.WarnContext(ctx, "delivery folded, all entities skipped", attrs...)
case st.UncoveredDropped > 0:
@@ -349,7 +371,35 @@ func (s *Service) finish(ctx context.Context, deliveryID string, out store.Parse
// большое тело, исчерпанный дедлайн — свойства самой доставки, и повторять их
// бесполезно: статус `failed`, тело ждёт пересборки. Приём при этом не
// затрагивается: сохранили значит приняли.
func (s *Service) fail(ctx context.Context, deliveryID string, cause error, uncovered []string) {
// parseResidue — то, что разбор успел узнать о доставке до отказа и что обязано
// пережить его в учёте: список непокрытых секций и число пропущенных сущностей.
//
// Структурой, а не двумя параметрами: у `fail` их стало бы четыре, и следующий
// счётчик неизбежно перепутали бы местами с предыдущим. Пустое значение —
// «разбор до этого не дошёл», и оно честно: отказ на чтении тела ничего о
// содержимом не знает.
type parseResidue struct {
uncovered []string
// skipped — nil означает «разбор до конца не дошёл, пропусков никто не
// считал». Ноль означал бы «проверено, терять нечего», а по этому числу
// ретеншен принимает необратимое решение об удалении тела.
skipped *int64
}
func residueOf(parsed hae.Result) parseResidue {
return parseResidue{uncovered: parsed.Uncovered}
}
// skippedEntities — сколько сущностей с собственным `id` разбор пропустил.
// Сумма трёх классов, а не три колонки: ретеншен спрашивает «есть ли что
// терять», а не «почему», а разбор класса живёт в логе свёртки, где все три
// счётчика идут атрибутами.
func skippedEntities(parsed hae.Result) *int64 {
n := int64(parsed.SkippedNoID + parsed.SkippedEntityNoTime + parsed.SkippedEntityMalformed)
return &n
}
func (s *Service) fail(ctx context.Context, deliveryID string, cause error, residue parseResidue) {
if store.Transient(cause) {
// WARN, а не ERROR: пройдёт само, разбирать нечего. Строка нужна, чтобы
// повтор не выглядел беспричинным.
@@ -375,9 +425,10 @@ func (s *Service) fail(ctx context.Context, deliveryID string, cause error, unco
// строка здесь оборвала бы цепочку наследования, то есть изменила бы
// результат пересборки журнала.
out := store.ParseOutcome{
Status: store.ParseFailed,
Layer: keepLayer,
Uncovered: uncovered,
Status: store.ParseFailed,
Layer: keepLayer,
Uncovered: residue.uncovered,
SkippedEntities: residue.skipped,
}
if err := s.finish(ctx, deliveryID, out); err != nil {
s.log.ErrorContext(ctx, "delivery parse status not recorded", "error", err, "delivery_id", deliveryID)