непокрытые секции доставки видны в статусе разбора
- половина потока (50 доставок из 104) не несёт metrics вовсе и до сих пор числилась parsed: ретеншен, поверив статусу, срезал бы тела stateOfMind, которых в экспорте Apple нет - разбор перечисляет верхнеуровневые ключи data, непокрытые проглатываются декодированием: тело 40 МиБ из непокрытой секции удерживает 0 МиБ - статус partial и колонка delivery.uncovered_sections; миграция переводит прежние parsed в pending — им верить нельзя - витрина не изменилась: отпечаток совпал с прогоном до изменения
This commit is contained in:
+46
-8
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -228,3 +229,98 @@ func mustHour(t *testing.T, st *store.Store, metric, layer string) time.Time {
|
||||
}
|
||||
return hours[0]
|
||||
}
|
||||
|
||||
// Доставка с непокрытой секцией обязана быть ОТЛИЧИМА от разобранной целиком.
|
||||
// Без этого ретеншен, ориентируясь на статус, срежет тело — а для stateOfMind
|
||||
// это необратимо: в экспорте Apple секции нет, доставки HAE единственный
|
||||
// источник.
|
||||
func TestFoldЧастичныйРазборВиденВУчёте(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, arch, st := newFold(t)
|
||||
ctx := context.Background()
|
||||
|
||||
deliver(t, arch, st, "d1", "Minutes", "a1", fixture(t, "uncovered_sections.json"))
|
||||
stats, err := f.Fold(ctx, "d1")
|
||||
if err != nil {
|
||||
t.Fatalf("свёртка: %v", err)
|
||||
}
|
||||
|
||||
if len(stats.Uncovered) == 0 {
|
||||
t.Error("список непокрытых секций пуст")
|
||||
}
|
||||
|
||||
d, err := st.LastDelivery(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение доставки: %v", err)
|
||||
}
|
||||
if d.ParseStatus != store.ParsePartial {
|
||||
t.Errorf("статус %q, ожидался %q", d.ParseStatus, store.ParsePartial)
|
||||
}
|
||||
if !strings.Contains(d.UncoveredSections, "stateOfMind") {
|
||||
t.Errorf("список в базе %q не содержит stateOfMind", d.UncoveredSections)
|
||||
}
|
||||
// Точки метрик обязаны сохраниться: частичность не отменяет разобранного.
|
||||
if stats.Points == 0 {
|
||||
t.Error("точек нет — покрытая секция потерялась вместе с непокрытой")
|
||||
}
|
||||
}
|
||||
|
||||
// Доставка из одних метрик списка не получает и остаётся parsed: частичность
|
||||
// производна от списка, а не назначена.
|
||||
func TestFoldПолныйРазборОстаётсяParsed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, arch, st := newFold(t)
|
||||
ctx := context.Background()
|
||||
|
||||
deliver(t, arch, st, "d1", "Minutes", "a1", fixture(t, "minute.json"))
|
||||
if _, err := f.Fold(ctx, "d1"); err != nil {
|
||||
t.Fatalf("свёртка: %v", err)
|
||||
}
|
||||
|
||||
d, err := st.LastDelivery(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение доставки: %v", err)
|
||||
}
|
||||
if d.ParseStatus != store.ParseDone {
|
||||
t.Errorf("статус %q, ожидался %q", d.ParseStatus, store.ParseDone)
|
||||
}
|
||||
if d.UncoveredSections != "[]" {
|
||||
t.Errorf("список %q, ожидался `[]` — ровно одно представление пустоты", d.UncoveredSections)
|
||||
}
|
||||
}
|
||||
|
||||
// Список замещает прежнее значение ЦЕЛИКОМ, включая замещение пустым. Иначе
|
||||
// доставка, чья секция стала покрытой, осталась бы partial навсегда, и
|
||||
// ретеншен вечно щадил бы тело, которое уже не нужно.
|
||||
func TestFoldПересвёрткаОчищаетСписок(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, arch, st := newFold(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Сперва доставка с непокрытой секцией.
|
||||
deliver(t, arch, st, "d1", "Minutes", "a1", fixture(t, "uncovered_sections.json"))
|
||||
if _, err := f.Fold(ctx, "d1"); err != nil {
|
||||
t.Fatalf("свёртка: %v", err)
|
||||
}
|
||||
|
||||
// Затем — та же доставка, но тело уже без непокрытых секций: так выглядит
|
||||
// пересвёртка после того, как секцию научились разбирать.
|
||||
deliver(t, arch, st, "d2", "Minutes", "a1", fixture(t, "minute.json"))
|
||||
if _, err := f.Fold(ctx, "d2"); err != nil {
|
||||
t.Fatalf("свёртка: %v", err)
|
||||
}
|
||||
|
||||
d, err := st.LastDelivery(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение доставки: %v", err)
|
||||
}
|
||||
if d.UncoveredSections != "[]" {
|
||||
t.Errorf("список %q не очистился — пустой список обязан замещать прежний", d.UncoveredSections)
|
||||
}
|
||||
if d.ParseStatus != store.ParseDone {
|
||||
t.Errorf("статус %q, ожидался %q", d.ParseStatus, store.ParseDone)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,3 +182,75 @@ func TestFoldНесравнимыеНаборыДаютWarn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Имена непокрытых секций в логе нужны — по ним видно, что поток принёс новое.
|
||||
// Содержимого секций там быть не может: это данные о здоровье. И уровень от
|
||||
// самой частичности не растёт — `partial` установившееся состояние половины
|
||||
// потока, а постоянный WARN каждые пять минут обесценивает уровень.
|
||||
func TestFoldЧастичныйРазборВЛоге(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
|
||||
dir := t.TempDir()
|
||||
arch, err := archive.New(filepath.Join(dir, "raw"))
|
||||
if err != nil {
|
||||
t.Fatalf("архив: %v", err)
|
||||
}
|
||||
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("база: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
f := fold.New(arch, st, 0, log)
|
||||
ctx := context.Background()
|
||||
|
||||
// Содержимое непокрытой секции помечено так, чтобы его нельзя было спутать
|
||||
// ни с чем: если оно окажется в логе, тест это увидит.
|
||||
const body = `{"data":{
|
||||
"metrics":[{"name":"step_count","units":"count","data":[
|
||||
{"date":"2025-06-05 10:00:00 +0300","qty":1},
|
||||
{"date":"2025-06-05 10:01:00 +0300","qty":2},
|
||||
{"date":"2025-06-05 10:02:00 +0300","qty":3},
|
||||
{"date":"2025-06-05 10:03:00 +0300","qty":4},
|
||||
{"date":"2025-06-05 10:04:00 +0300","qty":5},
|
||||
{"date":"2025-06-05 10:05:00 +0300","qty":6},
|
||||
{"date":"2025-06-05 10:06:00 +0300","qty":7},
|
||||
{"date":"2025-06-05 10:07:00 +0300","qty":8},
|
||||
{"date":"2025-06-05 10:08:00 +0300","qty":9},
|
||||
{"date":"2025-06-05 10:09:00 +0300","qty":10}]}],
|
||||
"stateOfMind":[{"valence":"СЕКРЕТНОЕ-НАСТРОЕНИЕ"}]}}`
|
||||
|
||||
deliver(t, arch, st, "d1", "Minutes", "a1", []byte(body))
|
||||
if _, err := f.Fold(ctx, "d1"); err != nil {
|
||||
t.Fatalf("свёртка: %v", err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "stateOfMind") {
|
||||
t.Error("имени непокрытой секции нет в логе — момент появления новой секции незаметен")
|
||||
}
|
||||
if strings.Contains(out, "СЕКРЕТНОЕ-НАСТРОЕНИЕ") {
|
||||
t.Error("содержимое непокрытой секции утекло в лог")
|
||||
}
|
||||
|
||||
var rec struct {
|
||||
Level string `json:"level"`
|
||||
Uncovered []string `json:"uncovered"`
|
||||
}
|
||||
line := strings.TrimSpace(out)
|
||||
if i := strings.LastIndex(line, "\n"); i >= 0 {
|
||||
line = line[i+1:]
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||
t.Fatalf("запись лога не разбирается: %v", err)
|
||||
}
|
||||
if rec.Level != "INFO" {
|
||||
t.Errorf("уровень %q, ожидался INFO: частичность — не отклонение", rec.Level)
|
||||
}
|
||||
if len(rec.Uncovered) != 1 || rec.Uncovered[0] != "stateOfMind" {
|
||||
t.Errorf("атрибут uncovered = %v, ожидался структурный список из stateOfMind", rec.Uncovered)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ func TestReplayЖивогоАрхива(t *testing.T) {
|
||||
|
||||
f, arch, st := newFold(t)
|
||||
ctx := context.Background()
|
||||
partial := 0
|
||||
sections := map[string]int{}
|
||||
|
||||
var folded, failed, incomparable int
|
||||
for _, path := range bodies {
|
||||
@@ -62,6 +64,12 @@ func TestReplayЖивогоАрхива(t *testing.T) {
|
||||
}
|
||||
folded++
|
||||
incomparable += res.Incomparable
|
||||
if len(res.Uncovered) > 0 {
|
||||
partial++
|
||||
for _, s := range res.Uncovered {
|
||||
sections[s]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Несравнимые наборы полей — посылка, на которой стоит отказ от объединения
|
||||
@@ -70,11 +78,19 @@ func TestReplayЖивогоАрхива(t *testing.T) {
|
||||
// сходимости.
|
||||
t.Logf("доставок %d: свёрнуто %d, не свёрнуто %d, несравнимых наборов %d",
|
||||
len(bodies), folded, failed, incomparable)
|
||||
t.Logf("частично разобрано %d, непокрытые секции: %v", partial, sections)
|
||||
|
||||
if folded == 0 {
|
||||
t.Fatal("ни одна доставка не свернулась")
|
||||
}
|
||||
|
||||
// Половина живого потока не несёт metrics вовсе (находка 50): такие
|
||||
// доставки обязаны быть отличимы от разобранных целиком, иначе ретеншен
|
||||
// срежет тела, которые для stateOfMind единственный источник.
|
||||
if partial == 0 {
|
||||
t.Error("ни одной частично разобранной доставки — перечисление непокрытых секций не работает")
|
||||
}
|
||||
|
||||
// Повторный прогон того же журнала не меняет состояния: свёртка
|
||||
// детерминирована, и пересборка даёт то же, что живой приём.
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user