// Package fold — свёртка доставки в часовые объекты. // // Хранилище — свёртка по журналу: состояние пересобирается как // `import(экспорт) + replay(доставки по received_at)`. Поэтому свёртка // принимает идентификатор доставки, а тело читает из архива — тем же кодом, // каким его прочитает пересборка. Разбор «из памяти, раз тело всё равно в // руках» дал бы второй путь, который разошёлся бы с первым молча. package fold import ( "context" "errors" "fmt" "io" "log/slog" "git.vakhrushev.me/av/healthlog/internal/archive" "git.vakhrushev.me/av/healthlog/internal/hae" "git.vakhrushev.me/av/healthlog/internal/store" ) // maxBodyBytes — граница размера тела при чтении из архива. // // Наблюдалось 42 МиБ; сотня даёт запас втрое и при этом не даёт битому или // враждебному архивному файлу выесть память процесса. Граница явная, потому // что молчаливое «сколько дадут» — это отказ, который проявится только на // пике потока. const maxBodyBytes = 100 << 20 // Service сворачивает доставки в часовые объекты. type Service struct { arch *archive.Archive store *store.Store log *slog.Logger } // New собирает свёртку. func New(arch *archive.Archive, st *store.Store, log *slog.Logger) *Service { return &Service{arch: arch, store: st, log: log.With("capability", "fold")} } // Stats — итог свёртки одной доставки. type Stats struct { Metrics int Points int Buckets int Unchanged int Overwrites int SealedHits int SkippedNoTime int SkippedMalformed int Layer string LayerMismatch bool } // Fold разбирает тело доставки и раскладывает точки по часовым объектам. // // Это единственный логирующий чекпоинт свёртки: транспорт и приём исход // разбора не логируют. Значения точек и имена устройств в лог не попадают — // данные о здоровье чувствительнее токенов. func (s *Service) Fold(ctx context.Context, deliveryID string) (Stats, error) { var stats Stats d, err := s.store.DeliveryForParse(ctx, deliveryID) if err != nil { return stats, err } body, err := s.readBody(d.RawPath) if err != nil { s.fail(ctx, deliveryID, err) return stats, err } fallback, err := s.store.LastDerivedLayer(ctx, d.AutomationID, d.ReceivedAt, d.ID) if err != nil { return stats, err } parsed, err := hae.Parse(body, hae.Meta{ Aggregation: d.Aggregation, FallbackLayer: hae.Layer(fallback), }) if err != nil { s.fail(ctx, deliveryID, err) return stats, err } stats.Metrics = parsed.Metrics stats.SkippedNoTime = parsed.SkippedNoTime stats.SkippedMalformed = parsed.SkippedMalformed stats.Layer = string(parsed.Layer) stats.LayerMismatch = parsed.LayerMismatch merge, err := s.store.MergePoints(ctx, toIncoming(parsed.Points), deliveryID) if err != nil { s.fail(ctx, deliveryID, err) return stats, err } stats.Points = merge.Points stats.Buckets = merge.Buckets stats.Unchanged = merge.Unchanged stats.Overwrites = merge.Overwrites stats.SealedHits = merge.SealedHits if err := s.store.FinishParse(ctx, deliveryID, store.ParseDone, int64(stats.Points), stats.Layer); err != nil { return stats, err } s.logResult(ctx, deliveryID, stats) return stats, nil } func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) { attrs := []any{ "delivery_id", deliveryID, "metrics", st.Metrics, "points", st.Points, "buckets", st.Buckets, "unchanged", st.Unchanged, "overwrites", st.Overwrites, "skipped_no_time", st.SkippedNoTime, "skipped_malformed", st.SkippedMalformed, "layer", st.Layer, } switch { case st.SealedHits > 0: // Досчёт часа, в который его уже не ждали: единственное наблюдение, по // которому вообще можно судить о глубине досчёта. s.log.WarnContext(ctx, "delivery folded, sealed hour changed", append(attrs, "sealed_hits", st.SealedHits)...) case st.LayerMismatch: // Расхождение сверяется только с надёжным заголовком: `Default` не // означает режима, и сравнение с ним давало бы WARN на каждой доставке. s.log.WarnContext(ctx, "delivery folded, layer differs from header", attrs...) default: s.log.InfoContext(ctx, "delivery folded", attrs...) } } // fail отмечает доставку неразобранной. Тело остаётся в архиве, и её подберёт // пересборка — приём при этом не затрагивается: сохранили значит приняли. func (s *Service) fail(ctx context.Context, deliveryID string, cause error) { level := slog.LevelError if errors.Is(cause, hae.ErrLayerUnknown) { // Слой не определился — это не поломка, а ожидаемый исход для доставки // без плотных метрик. Тело ждёт пересборки. level = slog.LevelWarn } s.log.Log(ctx, level, "delivery fold failed", "error", cause, "delivery_id", deliveryID) if err := s.store.FinishParse(ctx, deliveryID, store.ParseFailed, 0, ""); err != nil { s.log.ErrorContext(ctx, "delivery parse status not recorded", "error", err, "delivery_id", deliveryID) } } func (s *Service) readBody(rawPath string) ([]byte, error) { r, err := s.arch.Open(rawPath) if err != nil { return nil, err } defer func() { _ = r.Close() }() body, err := io.ReadAll(io.LimitReader(r, maxBodyBytes+1)) if err != nil { return nil, fmt.Errorf("чтение тела из архива: %w", err) } if len(body) > maxBodyBytes { return nil, fmt.Errorf("тело больше %d байт", maxBodyBytes) } return body, nil } func toIncoming(points []hae.Point) []store.IncomingPoint { out := make([]store.IncomingPoint, 0, len(points)) for _, p := range points { out = append(out, store.IncomingPoint{ Metric: p.Metric, Layer: string(p.Layer), Units: p.Units, Point: store.Point{ Start: p.Start, End: p.End, OffsetSeconds: p.OffsetSeconds, Raw: p.Raw, }, }) } return out }