свёртка доставки в объекты и сшивка с приёмом
- internal/fold — свёртка по идентификатору доставки, тело из архива: тот же код, каким пойдёт пересборка витрины - приём зовёт свёртку на context.WithoutCancel с собственным дедлайном; исход разбора на код ответа не влияет - слой доставки хранится в delivery.derived_layer и наследуется от ПРЕДШЕСТВУЮЩЕЙ доставки автоматизации: без границы по времени свёртка переставала быть функцией от префикса журнала (1737 объектов против 1742) - task verify:archive — сходимость на живом архиве, 99 доставок из 99
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user