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

- половина потока (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)
}
}
+96
View File
@@ -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)
}
}
+72
View File
@@ -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)
}
}
+16
View File
@@ -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("ни одной частично разобранной доставки — перечисление непокрытых секций не работает")
}
// Повторный прогон того же журнала не меняет состояния: свёртка
// детерминирована, и пересборка даёт то же, что живой приём.
//