непокрытые секции доставки видны в статусе разбора

- половина потока (50 доставок из 104) не несёт metrics вовсе и до сих пор
  числилась parsed: ретеншен, поверив статусу, срезал бы тела stateOfMind,
  которых в экспорте Apple нет
- разбор перечисляет верхнеуровневые ключи data, непокрытые проглатываются
  декодированием: тело 40 МиБ из непокрытой секции удерживает 0 МиБ
- статус partial и колонка delivery.uncovered_sections; миграция переводит
  прежние parsed в pending — им верить нельзя
- витрина не изменилась: отпечаток совпал с прогоном до изменения
This commit is contained in:
av
2026-08-01 21:25:59 +03:00
parent 7a7594e3e7
commit 34e5109b6d
26 changed files with 1808 additions and 89 deletions
+46 -8
View File
@@ -82,6 +82,12 @@ type Stats struct {
LayerMismatch bool
Collisions []store.Collision
IncomparableAt []store.Collision
// Uncovered — верхнеуровневые ключи `data`, которых разбор не покрывает.
// Это ответ на вопрос «что останется потерянным, если тело удалить»:
// для stateOfMind он необратим — в экспорте Apple этой секции нет.
Uncovered []string
// UncoveredDropped — сколько имён отброшено границей списка.
UncoveredDropped int
}
// Fold разбирает тело доставки и раскладывает точки по часовым объектам.
@@ -103,7 +109,7 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (Stats, error) {
body, err := s.readBody(d.RawPath)
if err != nil {
s.fail(ctx, deliveryID, err)
s.fail(ctx, deliveryID, err, nil)
return stats, err
}
@@ -118,10 +124,15 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (Stats, error) {
FallbackLayer: hae.Layer(fallback),
})
if err != nil {
s.fail(ctx, deliveryID, err)
// Список непокрытых секций переживает отказ: доставка, у которой не
// определился слой, обязана остаться записью о том, что в теле есть
// невосстановимая секция.
s.fail(ctx, deliveryID, err, parsed.Uncovered)
return stats, err
}
stats.Uncovered = parsed.Uncovered
stats.UncoveredDropped = parsed.UncoveredDropped
stats.Metrics = parsed.Metrics
stats.Points = len(parsed.Points)
stats.SkippedNoTime = parsed.SkippedNoTime
@@ -132,7 +143,7 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (Stats, error) {
merge, err := s.store.MergePoints(ctx, toIncoming(parsed.Points), deliveryID)
if err != nil {
s.fail(ctx, deliveryID, err)
s.fail(ctx, deliveryID, err, parsed.Uncovered)
return stats, err
}
@@ -146,7 +157,20 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (Stats, error) {
stats.Collisions = merge.Collisions
stats.IncomparableAt = merge.IncomparableAt
if err := s.finish(ctx, deliveryID, store.ParseDone, int64(stats.Points), stats.Layer); err != nil {
// Источник истины — список; статус производен от него и от факта отказа.
// Приоритет назван явно, иначе два будущих читателя (ретеншен и /stats)
// разойдутся: один спросит parse_status, другой — непустоту списка.
status := store.ParseDone
if len(parsed.Uncovered) > 0 {
status = store.ParsePartial
}
out := store.ParseOutcome{
Status: status,
Points: int64(stats.Points),
Layer: stats.Layer,
Uncovered: parsed.Uncovered,
}
if err := s.finish(ctx, deliveryID, out); err != nil {
s.log.ErrorContext(ctx, "delivery fold failed", "error", err, "delivery_id", deliveryID)
return stats, err
}
@@ -186,6 +210,11 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
"skipped_bad_end", st.SkippedBadEnd,
"layer", st.Layer,
"layer_mismatch", st.LayerMismatch,
// Структурным []string, а не склейкой: JSON-кодировщик slog экранирует
// управляющие символы, поэтому имя секции из чужого тела не разрывает
// построчный разбор логов. Содержимого секций здесь нет.
"uncovered", st.Uncovered,
"uncovered_dropped", st.UncoveredDropped,
}
if len(st.Collisions) > 0 {
attrs = append(attrs, "collisions", formatCollisions(st.Collisions))
@@ -200,6 +229,10 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
allSkipped := st.Points == 0 && skipped > 0
switch {
case st.UncoveredDropped > 0:
// Не частичный разбор, а тело, не похожее на HAE: секций у HAE восемь,
// а границу выбило больше тридцати двух.
s.log.WarnContext(ctx, "delivery folded, uncovered section list truncated", attrs...)
case st.Incomparable > 0:
// Выше перезаписей намеренно: несравнимый набор полей — событие реже и
// информативнее, на живом потоке не случавшееся ни разу. Признаки при
@@ -239,11 +272,11 @@ func formatCollisions(cs []store.Collision) string {
const keepLayer = ""
// finish записывает исход разбора на контексте, переживающем отмену исходного.
func (s *Service) finish(ctx context.Context, deliveryID, status string, points int64, layer string) error {
func (s *Service) finish(ctx context.Context, deliveryID string, out store.ParseOutcome) error {
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), finishTimeout)
defer cancel()
if err := s.store.FinishParse(ctx, deliveryID, status, points, layer); err != nil {
if err := s.store.FinishParse(ctx, deliveryID, out); err != nil {
return fmt.Errorf("запись исхода разбора: %w", err)
}
return nil
@@ -251,7 +284,7 @@ func (s *Service) finish(ctx context.Context, deliveryID, status string, points
// fail отмечает доставку неразобранной. Тело остаётся в архиве, и её подберёт
// пересборка — приём при этом не затрагивается: сохранили значит приняли.
func (s *Service) fail(ctx context.Context, deliveryID string, cause error) {
func (s *Service) fail(ctx context.Context, deliveryID string, cause error, uncovered []string) {
level := slog.LevelError
switch {
case errors.Is(cause, hae.ErrLayerUnknown):
@@ -269,7 +302,12 @@ func (s *Service) fail(ctx context.Context, deliveryID string, cause error) {
// Слой НЕ затирается: доставка могла свернуться успешно раньше, и пустая
// строка здесь оборвала бы цепочку наследования, то есть изменила бы
// результат пересборки журнала.
if err := s.finish(ctx, deliveryID, store.ParseFailed, 0, keepLayer); err != nil {
out := store.ParseOutcome{
Status: store.ParseFailed,
Layer: keepLayer,
Uncovered: uncovered,
}
if err := s.finish(ctx, deliveryID, out); err != nil {
s.log.ErrorContext(ctx, "delivery parse status not recorded", "error", err, "delivery_id", deliveryID)
}
}