первая встреча непокрытой секции стала наблюдаемым событием
- свёртка спрашивает журнал, встречалось ли имя строго раньше по паре (received_at, id), и пишет WARN с атрибутом uncovered_new; повторные молчат. Признак выводится, а не хранится — реестр был бы второй копией факта - добавлена подкоманда `healthlog uncovered`: перечень накопленного, чтение только на чтение, экранированные имена и названные границы носителя - синк документации: ADR о выводе новизны из журнала, две записи в журнал дефектов, два правила промоутом в конвенции, терминал оператора назван адресатом недоверенного входа
This commit is contained in:
+119
-4
@@ -92,6 +92,17 @@ type Stats struct {
|
||||
Uncovered []string
|
||||
// UncoveredDropped — сколько имён отброшено границей списка.
|
||||
UncoveredDropped int
|
||||
// UncoveredNew — имена непокрытых секций, которых не было ни в одной
|
||||
// доставке, стоящей в журнале раньше этой. Событие однократное за всю жизнь
|
||||
// имени: поток дописывает метрики на телефоне молча, и момент появления
|
||||
// секции наблюдать больше нечем.
|
||||
UncoveredNew []string
|
||||
// UncoveredSeenUnknown — сверка с журналом не состоялась, и потому все
|
||||
// непокрытые имена доставки объявлены новыми. Лишняя запись стоит внимания
|
||||
// один раз, промолчавшее событие не восстанавливается ничем.
|
||||
UncoveredSeenUnknown bool
|
||||
// UncoveredSeenError — почему не состоялась.
|
||||
UncoveredSeenError error
|
||||
|
||||
// Categoricals — сколько РАЗЛИЧНЫХ категориальных значений наблюдалось;
|
||||
// CategoricalUnknown — сколько из них словарь не знает;
|
||||
@@ -158,6 +169,16 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
|
||||
FallbackLayer: hae.Layer(fallback),
|
||||
Locale: localeOf(d.Headers),
|
||||
})
|
||||
|
||||
// Сверка с журналом идёт ДО ветвления на успех и отказ. Список непокрытых
|
||||
// секций переживает отказ разбора, то есть имя уже записано в учёт; смолчи
|
||||
// здесь — и следующая доставка сочтёт его виденным, а событие не вернётся
|
||||
// ничем, кроме ручного запроса в базу.
|
||||
novelty := s.novelty(ctx, parsed.Uncovered, store.DeliveryRef{
|
||||
ID: d.ID,
|
||||
ReceivedAt: d.ReceivedAt,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// Список непокрытых секций переживает отказ: доставка, у которой не
|
||||
// определился слой, обязана остаться записью о том, что в теле есть
|
||||
@@ -166,12 +187,15 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
|
||||
// А вот число пропущенных сущностей — НЕ переживает: разбор, вернувший
|
||||
// ошибку, отдаёт нулевые счётчики по построению, а не по измерению, и
|
||||
// записать этот ноль значило бы объявить доставку проверенной.
|
||||
s.fail(ctx, deliveryID, err, residueOf(parsed))
|
||||
s.fail(ctx, deliveryID, err, residueOf(parsed, novelty))
|
||||
return stats, err
|
||||
}
|
||||
|
||||
stats.Uncovered = parsed.Uncovered
|
||||
stats.UncoveredDropped = parsed.UncoveredDropped
|
||||
stats.UncoveredNew = novelty.fresh
|
||||
stats.UncoveredSeenUnknown = novelty.unknown
|
||||
stats.UncoveredSeenError = novelty.cause
|
||||
stats.Metrics = parsed.Metrics
|
||||
stats.Points = len(parsed.Points)
|
||||
stats.SkippedNoTime = parsed.SkippedNoTime
|
||||
@@ -196,6 +220,11 @@ func (s *Service) Fold(ctx context.Context, deliveryID string) (stats Stats, err
|
||||
s.fail(ctx, deliveryID, err, parseResidue{
|
||||
uncovered: parsed.Uncovered,
|
||||
skipped: skippedEntities(parsed),
|
||||
// Новизна доезжает и сюда. Эта ветвь пишет имя в учёт ровно так же,
|
||||
// как ветвь отказа разбора, — значит и терять событие ей нельзя:
|
||||
// следующая доставка сочтёт имя виденным, а отказ слияния бывает
|
||||
// нетранзиентным (исчерпанный дедлайн свёртки под большим телом).
|
||||
novelty: novelty,
|
||||
})
|
||||
return stats, err
|
||||
}
|
||||
@@ -283,6 +312,18 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
|
||||
// построчный разбор логов. Содержимого секций здесь нет.
|
||||
"uncovered", st.Uncovered,
|
||||
"uncovered_dropped", st.UncoveredDropped,
|
||||
// Имена, встреченные впервые по журналу, — атрибутом ВСЕГДА, а уровень
|
||||
// поднимается отдельной ветвью ниже. Наблюдаемый признак события это
|
||||
// он: имя непокрытой секции стоит в атрибуте `uncovered` у каждой
|
||||
// доставки, которая её принесла, и по нему первую встречу не отличить.
|
||||
"uncovered_new", st.UncoveredNew,
|
||||
"uncovered_seen_unknown", st.UncoveredSeenUnknown,
|
||||
// Причина несостоявшейся сверки — рядом с признаком. Занятость базы
|
||||
// проходит сама, испорченная колонка не проходит никогда и поднимает
|
||||
// признак на каждой доставке; по одному булеву это неразличимо.
|
||||
// Значений точек в ошибке нет: до текста доезжает только имя секции, и
|
||||
// оно обрезано.
|
||||
"uncovered_seen_error", st.UncoveredSeenError,
|
||||
// Категориальные значения — ЧИСЛАМИ. Ни строк, ни выведенных кодов:
|
||||
// «Сидячий образ жизни» — это контекст пульса, то есть данные о
|
||||
// здоровье. Какие именно строки ждут словаря, отвечает реестр в базе.
|
||||
@@ -318,6 +359,15 @@ func (s *Service) logResult(ctx context.Context, deliveryID string, st Stats) {
|
||||
allEntitiesSkipped := st.Workouts == 0 && st.Records == 0 && skippedEntities > 0
|
||||
|
||||
switch {
|
||||
case len(st.UncoveredNew) > 0:
|
||||
// ПЕРВОЙ ветвью, и это существенно. Все прочие говорят о событиях,
|
||||
// повторяющихся на живом потоке; это — однократное за всю жизнь имени, и
|
||||
// замаскировать его перезаписью точек значило бы потерять ровно то, ради
|
||||
// чего наблюдение заведено. Уровень `WARN`, а не `ERROR`: приезд новой
|
||||
// секции — штатное событие внешнего мира, «посмотри», а не «разбери
|
||||
// сбой». Имена секций в лог попадать могут: имя ключа — форма пакета, а
|
||||
// не измерение.
|
||||
s.log.WarnContext(ctx, "delivery folded, new uncovered section", attrs...)
|
||||
case st.EntitiesHeld > 0:
|
||||
// Приехавшая версия сущности отклонена как теряющая содержание. Плата
|
||||
// за отказ объединять поля: событие обязано быть видно, потому что на
|
||||
@@ -446,10 +496,61 @@ type parseResidue struct {
|
||||
// считал». Ноль означал бы «проверено, терять нечего», а по этому числу
|
||||
// ретеншен принимает необратимое решение об удалении тела.
|
||||
skipped *int64
|
||||
// novelty в учёте не участвует — она едет в запись лога об отказе. Полем, а
|
||||
// не пятым параметром `fail`: параметры путают местами, а поле называет
|
||||
// себя само.
|
||||
novelty sectionNovelty
|
||||
}
|
||||
|
||||
func residueOf(parsed hae.Result) parseResidue {
|
||||
return parseResidue{uncovered: parsed.Uncovered}
|
||||
func residueOf(parsed hae.Result, novelty sectionNovelty) parseResidue {
|
||||
return parseResidue{uncovered: parsed.Uncovered, novelty: novelty}
|
||||
}
|
||||
|
||||
// sectionNovelty — исход сверки имён непокрытых секций с журналом.
|
||||
type sectionNovelty struct {
|
||||
// fresh — имена, которых не было ни в одной доставке раньше этой.
|
||||
fresh []string
|
||||
// unknown — сверка не состоялась, и потому новыми объявлены ВСЕ имена
|
||||
// доставки.
|
||||
unknown bool
|
||||
// cause — почему не состоялась. Без неё занятость базы (пройдёт сама) и
|
||||
// испорченное содержимое колонки (не пройдёт никогда, и признак будет
|
||||
// подниматься на каждой доставке) неотличимы, а разбираться пришлось бы тем
|
||||
// самым ручным запросом в базу, от которого задача избавляет.
|
||||
cause error
|
||||
}
|
||||
|
||||
// novelty спрашивает журнал, какие из непокрытых имён встречаются впервые.
|
||||
//
|
||||
// Отказ запроса свёртку не роняет и исходом доставки не становится: правила
|
||||
// классификации исходов наблюдение не трогает, занятая база и отменённый
|
||||
// контекст остаются обстоятельствами. Но и молчания здесь быть не может — имя,
|
||||
// о котором смолчали, уже записано в учёт, — поэтому при отказе новыми
|
||||
// объявляются все имена, а признак несостоявшейся сверки идёт в запись.
|
||||
//
|
||||
// Запрос берётся только при непустом списке: на живом потоке все три
|
||||
// приезжающие секции покрыты, то есть в штатном режиме сверка не стоит ничего.
|
||||
func (s *Service) novelty(ctx context.Context, uncovered []string, at store.DeliveryRef) sectionNovelty {
|
||||
if len(uncovered) == 0 {
|
||||
return sectionNovelty{}
|
||||
}
|
||||
|
||||
seen, err := s.store.SectionsSeenBefore(ctx, uncovered, at)
|
||||
if err != nil {
|
||||
return sectionNovelty{fresh: uncovered, unknown: true, cause: err}
|
||||
}
|
||||
|
||||
fresh := make([]string, 0, len(uncovered))
|
||||
for _, name := range uncovered {
|
||||
if _, ok := seen[name]; ok {
|
||||
continue
|
||||
}
|
||||
fresh = append(fresh, name)
|
||||
}
|
||||
if len(fresh) == 0 {
|
||||
return sectionNovelty{}
|
||||
}
|
||||
return sectionNovelty{fresh: fresh}
|
||||
}
|
||||
|
||||
// skippedEntities — сколько сущностей с собственным `id` разбор пропустил.
|
||||
@@ -469,6 +570,20 @@ func (s *Service) fail(ctx context.Context, deliveryID string, cause error, resi
|
||||
return
|
||||
}
|
||||
|
||||
// Новые имена доезжают до записи об отказе: она уже выше рутинного уровня,
|
||||
// а событие опознаётся атрибутом. В отложенном исходе выше их нет намеренно
|
||||
// — там учётная запись не меняется, доставка вернётся следующим проходом, и
|
||||
// повторение признака на каждом проходе занятой базы превратило бы
|
||||
// однократное событие в дребезг.
|
||||
attrs := []any{"error", cause, "delivery_id", deliveryID}
|
||||
if len(residue.novelty.fresh) > 0 {
|
||||
attrs = append(attrs, "uncovered_new", residue.novelty.fresh)
|
||||
}
|
||||
if residue.novelty.unknown {
|
||||
attrs = append(attrs, "uncovered_seen_unknown", true,
|
||||
"uncovered_seen_error", residue.novelty.cause)
|
||||
}
|
||||
|
||||
level := slog.LevelError
|
||||
switch {
|
||||
case errors.Is(cause, hae.ErrLayerUnknown):
|
||||
@@ -481,7 +596,7 @@ func (s *Service) fail(ctx context.Context, deliveryID string, cause error, resi
|
||||
// одного класса ошибки давали бы постоянный ERROR-шум.
|
||||
level = slog.LevelWarn
|
||||
}
|
||||
s.log.Log(ctx, level, "delivery fold failed", "error", cause, "delivery_id", deliveryID)
|
||||
s.log.Log(ctx, level, "delivery fold failed", attrs...)
|
||||
|
||||
// Слой НЕ затирается: доставка могла свернуться успешно раньше, и пустая
|
||||
// строка здесь оборвала бы цепочку наследования, то есть изменила бы
|
||||
|
||||
@@ -238,6 +238,15 @@ func TestFoldЧастичныйРазборВЛоге(t *testing.T) {
|
||||
t.Fatalf("свёртка: %v", err)
|
||||
}
|
||||
|
||||
// Смотрим на ВТОРУЮ доставку с той же секцией. Первая встреча имени —
|
||||
// событие само по себе и уровень поднимает законно; здесь же проверяется
|
||||
// другое: что уровень не поднимает сама по себе частичность, установившееся
|
||||
// состояние половины потока.
|
||||
deliver(t, arch, st, "d2", "Minutes", "a1", []byte(body))
|
||||
if _, err := f.Fold(ctx, "d2"); err != nil {
|
||||
t.Fatalf("повторная свёртка: %v", err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "ecg") {
|
||||
t.Error("имени непокрытой секции нет в логе — момент появления новой секции незаметен")
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package fold
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
// Внутренний тест, а не через `Fold`, и это осознанно. Сломать сверку снаружи
|
||||
// больше нечем: испорченную учётную запись запрос теперь пропускает, а
|
||||
// единственная настоящая причина отказа — отмена контекста и занятость базы —
|
||||
// в полном проходе свёртки утащила бы за собой и слияние, то есть проверялась
|
||||
// бы уже другая ветвь. Заводить интерфейс хранилища ради мока дороже: он
|
||||
// пережил бы тест и остался бы в коде навсегда.
|
||||
func TestNoveltyОтменённыйКонтекстНеМолчит(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("база: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
s := &Service{store: st, log: slog.New(slog.DiscardHandler)}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
got := s.novelty(ctx, []string{"ecg", "symptoms"}, store.DeliveryRef{
|
||||
ID: "d1",
|
||||
ReceivedAt: time.Now().UTC(),
|
||||
})
|
||||
|
||||
if !got.unknown {
|
||||
t.Fatal("сверка на отменённом контексте объявила себя состоявшейся")
|
||||
}
|
||||
if len(got.fresh) != 2 {
|
||||
t.Errorf("новыми объявлены %v, ожидались все имена доставки", got.fresh)
|
||||
}
|
||||
if got.cause == nil {
|
||||
t.Error("причина отказа потеряна: занятость базы и вечную порчу колонки по признаку не различить")
|
||||
}
|
||||
}
|
||||
|
||||
// Пустой список сверку не берёт вовсе: на живом потоке все три приезжающие
|
||||
// секции покрыты, и запрос по журналу в штатном режиме не стоит ничего.
|
||||
func TestNoveltyПустойСписокЗапросаНеБерёт(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := &Service{store: nil, log: slog.New(slog.DiscardHandler)}
|
||||
|
||||
// Хранилище nil: возьмись запрос — тест упал бы паникой.
|
||||
got := s.novelty(context.Background(), nil, store.DeliveryRef{ID: "d1"})
|
||||
if got.unknown || len(got.fresh) != 0 {
|
||||
t.Errorf("на пустом списке сверка что-то решила: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Признак несостоявшейся сверки без причины неразбираем: занятость базы пройдёт
|
||||
// сама, испорченная колонка не пройдёт никогда и будет поднимать признак на
|
||||
// каждой доставке.
|
||||
func TestLogResultПечатаетПричинуНесостоявшейсяСверки(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
s := &Service{log: slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))}
|
||||
|
||||
s.logResult(context.Background(), "d1", Stats{
|
||||
Uncovered: []string{"ecg"},
|
||||
UncoveredNew: []string{"ecg"},
|
||||
UncoveredSeenUnknown: true,
|
||||
UncoveredSeenError: errors.New("журнал недоступен"),
|
||||
})
|
||||
|
||||
var rec map[string]any
|
||||
line := strings.TrimSpace(buf.String())
|
||||
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||
t.Fatalf("запись не разбирается: %v", err)
|
||||
}
|
||||
if rec["uncovered_seen_unknown"] != true {
|
||||
t.Error("признак несостоявшейся сверки не выставлен")
|
||||
}
|
||||
if rec["uncovered_seen_error"] != "журнал недоступен" {
|
||||
t.Errorf("причина в записи: %v", rec["uncovered_seen_error"])
|
||||
}
|
||||
if rec["level"] != "WARN" {
|
||||
t.Errorf("уровень %v, ожидался WARN: имена объявлены новыми", rec["level"])
|
||||
}
|
||||
}
|
||||
|
||||
// Запись об отказе несёт и признак несостоявшейся сверки, и причину: у отказа
|
||||
// разбора та же цена молчания, что у успешного пути — имя уже в учёте.
|
||||
func TestFailПечатаетНесостоявшуюсяСверку(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("база: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
ctx := context.Background()
|
||||
err = st.CreateDelivery(ctx, store.Delivery{
|
||||
ID: "d1", ReceivedAt: time.Now().UTC(), RawPath: "d1.json.gz",
|
||||
SHA256: "-", ParseStatus: store.ParsePending,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("учёт доставки: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
s := &Service{
|
||||
store: st,
|
||||
log: slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})),
|
||||
}
|
||||
|
||||
s.fail(ctx, "d1", errors.New("непонятое содержимое"), parseResidue{
|
||||
uncovered: []string{"ecg"},
|
||||
novelty: sectionNovelty{fresh: []string{"ecg"}, unknown: true, cause: errors.New("журнал недоступен")},
|
||||
})
|
||||
|
||||
out := buf.String()
|
||||
for _, want := range []string{"uncovered_new", "ecg", "uncovered_seen_unknown", "журнал недоступен"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("в записи об отказе нет %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package fold_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/archive"
|
||||
"git.vakhrushev.me/av/healthlog/internal/fold"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
|
||||
_ "modernc.org/sqlite" // прямое подключение к файлу базы — ради теста, ломающего сверку
|
||||
)
|
||||
|
||||
// loggedFold — свёртка с логгером, чьи записи можно прочитать.
|
||||
//
|
||||
// Отдельный конструктор, потому что событие о новой секции наблюдаемо ТОЛЬКО
|
||||
// через лог: в учёте его нет и быть не может — колонка говорит, какие секции в
|
||||
// теле есть, а не какая из них встретилась впервые. Путь к файлу базы нужен
|
||||
// тесту, ломающему сверку: сломать её изнутри нечем — свёртка держит настоящее
|
||||
// хранилище, а заводить интерфейс ради мока значит менять код под тест.
|
||||
type loggedFold struct {
|
||||
svc *fold.Service
|
||||
arch *archive.Archive
|
||||
st *store.Store
|
||||
log *bytes.Buffer
|
||||
dbPath string
|
||||
}
|
||||
|
||||
func newLoggedFold(t *testing.T) loggedFold {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
arch, err := archive.New(filepath.Join(dir, "raw"))
|
||||
if err != nil {
|
||||
t.Fatalf("архив: %v", err)
|
||||
}
|
||||
dbPath := filepath.Join(dir, "healthlog.db")
|
||||
st, err := store.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("база: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
return loggedFold{
|
||||
svc: fold.New(arch, st, 0, log),
|
||||
arch: arch,
|
||||
st: st,
|
||||
log: &buf,
|
||||
dbPath: dbPath,
|
||||
}
|
||||
}
|
||||
|
||||
// breakMerge сносит таблицу объектов: слияние отказывает нетранзиентно, а разбор
|
||||
// и запись исхода продолжают работать.
|
||||
//
|
||||
// В жизни в ту же ветвь ведёт исчерпанный дедлайн свёртки под большим телом
|
||||
// (измерено: 63 МиБ держат блокировку 5.019 с) — `store.Transient` дедлайн
|
||||
// намеренно не признаёт.
|
||||
func breakMerge(t *testing.T, dbPath string) {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite", "file:"+dbPath+"?_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
t.Fatalf("открытие базы напрямую: %v", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
if _, err := db.ExecContext(context.Background(), `DROP TABLE bucket`); err != nil {
|
||||
t.Fatalf("снос таблицы объектов: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// logRecords разбирает захваченные записи лога.
|
||||
func logRecords(t *testing.T, buf *bytes.Buffer) []map[string]any {
|
||||
t.Helper()
|
||||
|
||||
var out []map[string]any
|
||||
for line := range strings.SplitSeq(strings.TrimSpace(buf.String()), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var rec map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||
t.Fatalf("строка лога не JSON: %v", err)
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// newSections достаёт имена новых секций из записи лога.
|
||||
func newSections(t *testing.T, rec map[string]any) []string {
|
||||
t.Helper()
|
||||
|
||||
raw, ok := rec["uncovered_new"]
|
||||
if !ok || raw == nil {
|
||||
// Пустой список кодировщик пишет как `null` — это и есть «новых имён
|
||||
// нет», а не сломанный атрибут.
|
||||
return nil
|
||||
}
|
||||
list, ok := raw.([]any)
|
||||
if !ok {
|
||||
t.Fatalf("атрибут uncovered_new не список: %#v", raw)
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for _, v := range list {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
t.Fatalf("имя секции не строка: %#v", v)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// withSection дописывает в тело непокрытую секцию с данным именем.
|
||||
func withSection(t *testing.T, body []byte, name string) []byte {
|
||||
t.Helper()
|
||||
|
||||
const anchor = `"data": {`
|
||||
if !bytes.Contains(body, []byte(anchor)) {
|
||||
t.Fatalf("в теле нет объекта data — дописать секцию некуда")
|
||||
}
|
||||
return bytes.Replace(body, []byte(anchor), []byte(anchor+"\n \""+name+"\": [],"), 1)
|
||||
}
|
||||
|
||||
// Момент появления секции наблюдать больше нечем: телефон дописывает метрики
|
||||
// молча, а колонка учёта говорит, какие секции в теле есть, а не какая из них
|
||||
// приехала впервые.
|
||||
func TestFoldПерваяВстречаСекцииДаётСобытие(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lf := newLoggedFold(t)
|
||||
ctx := context.Background()
|
||||
body := withSection(t, fixture(t, "minute.json"), "symptoms")
|
||||
|
||||
deliver(t, lf.arch, lf.st, "d1", "Minutes", "auto-1", body)
|
||||
stats, err := lf.svc.Fold(ctx, "d1")
|
||||
if err != nil {
|
||||
t.Fatalf("свёртка: %v", err)
|
||||
}
|
||||
|
||||
if len(stats.UncoveredNew) != 1 || stats.UncoveredNew[0] != "symptoms" {
|
||||
t.Fatalf("новых секций %v, ожидалась ровно `symptoms`", stats.UncoveredNew)
|
||||
}
|
||||
|
||||
recs := logRecords(t, lf.log)
|
||||
var announced int
|
||||
for _, rec := range recs {
|
||||
for _, name := range newSections(t, rec) {
|
||||
if name != "symptoms" {
|
||||
continue
|
||||
}
|
||||
announced++
|
||||
if rec["level"] != "WARN" {
|
||||
t.Errorf("уровень записи %v, ожидался WARN: приезд новой секции — «посмотри», а не «разбери сбой»",
|
||||
rec["level"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if announced != 1 {
|
||||
t.Errorf("записей о новой секции %d, ожидалась ровно одна", announced)
|
||||
}
|
||||
}
|
||||
|
||||
// Вторая доставка с тем же именем молчит: событие однократно за всю жизнь
|
||||
// имени, иначе поток раз в пять минут обесценил бы уровень.
|
||||
func TestFoldВтораяВстречаСобытияНеДаёт(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lf := newLoggedFold(t)
|
||||
ctx := context.Background()
|
||||
body := withSection(t, fixture(t, "minute.json"), "symptoms")
|
||||
|
||||
deliver(t, lf.arch, lf.st, "d1", "Minutes", "auto-1", body)
|
||||
if _, err := lf.svc.Fold(ctx, "d1"); err != nil {
|
||||
t.Fatalf("первая свёртка: %v", err)
|
||||
}
|
||||
lf.log.Reset()
|
||||
|
||||
deliver(t, lf.arch, lf.st, "d2", "Minutes", "auto-1", body)
|
||||
stats, err := lf.svc.Fold(ctx, "d2")
|
||||
if err != nil {
|
||||
t.Fatalf("вторая свёртка: %v", err)
|
||||
}
|
||||
|
||||
if len(stats.UncoveredNew) != 0 {
|
||||
t.Errorf("вторая доставка объявила новыми %v", stats.UncoveredNew)
|
||||
}
|
||||
|
||||
recs := logRecords(t, lf.log)
|
||||
if len(recs) == 0 {
|
||||
t.Fatal("вторая свёртка не записала ничего — чекпоинт молчит вовсе")
|
||||
}
|
||||
for _, rec := range recs {
|
||||
if len(newSections(t, rec)) != 0 {
|
||||
t.Errorf("вторая доставка назвала новые секции: %v", rec)
|
||||
}
|
||||
if rec["msg"] == "delivery folded" && rec["level"] != "INFO" {
|
||||
t.Errorf("уровень повторной доставки %v, ожидался INFO", rec["level"])
|
||||
}
|
||||
}
|
||||
|
||||
// Перечисление непокрытых секций при этом не меняется: атрибут `uncovered`
|
||||
// продолжает называть их все — по нему первую встречу и не отличить.
|
||||
if !strings.Contains(lf.log.String(), "symptoms") {
|
||||
t.Error("имя секции пропало из записи вовсе: атрибут непокрытых секций не трогается")
|
||||
}
|
||||
}
|
||||
|
||||
// Новое имя рядом с уже виденным: событие адресное, а не «в теле что-то новое».
|
||||
func TestFoldНовоеИмяРядомСВиденным(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lf := newLoggedFold(t)
|
||||
ctx := context.Background()
|
||||
first := withSection(t, fixture(t, "minute.json"), "symptoms")
|
||||
second := withSection(t, first, "ecg")
|
||||
|
||||
deliver(t, lf.arch, lf.st, "d1", "Minutes", "auto-1", first)
|
||||
if _, err := lf.svc.Fold(ctx, "d1"); err != nil {
|
||||
t.Fatalf("первая свёртка: %v", err)
|
||||
}
|
||||
|
||||
deliver(t, lf.arch, lf.st, "d2", "Minutes", "auto-1", second)
|
||||
stats, err := lf.svc.Fold(ctx, "d2")
|
||||
if err != nil {
|
||||
t.Fatalf("вторая свёртка: %v", err)
|
||||
}
|
||||
|
||||
if len(stats.UncoveredNew) != 1 || stats.UncoveredNew[0] != "ecg" {
|
||||
t.Errorf("новых секций %v, ожидалась ровно `ecg`", stats.UncoveredNew)
|
||||
}
|
||||
}
|
||||
|
||||
// Событие однократно за жизнь имени, значит замаскировать его рутинным
|
||||
// событием нельзя: перезаписи точек случаются на живом потоке постоянно, а
|
||||
// приезд новой секции — один раз.
|
||||
func TestFoldНоваяСекцияНеМаскируетсяПерезаписью(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lf := newLoggedFold(t)
|
||||
ctx := context.Background()
|
||||
|
||||
base := fixture(t, "minute.json")
|
||||
deliver(t, lf.arch, lf.st, "d1", "Minutes", "auto-1", base)
|
||||
if _, err := lf.svc.Fold(ctx, "d1"); err != nil {
|
||||
t.Fatalf("первая свёртка: %v", err)
|
||||
}
|
||||
lf.log.Reset()
|
||||
|
||||
// Те же координаты, другое значение: набор полей совпадает, полнота равна,
|
||||
// поэтому побеждает пришедшая — это перезапись.
|
||||
changed := regexp.MustCompile(`"qty"\s*:\s*[-0-9.eE+]+`).ReplaceAll(base, []byte(`"qty": 777.5`))
|
||||
if bytes.Equal(changed, base) {
|
||||
t.Fatal("фикстура не содержит qty — перезапись не устроить")
|
||||
}
|
||||
deliver(t, lf.arch, lf.st, "d2", "Minutes", "auto-1", withSection(t, changed, "ecg"))
|
||||
stats, err := lf.svc.Fold(ctx, "d2")
|
||||
if err != nil {
|
||||
t.Fatalf("вторая свёртка: %v", err)
|
||||
}
|
||||
if stats.Overwrites == 0 {
|
||||
t.Fatal("перезаписей нет — рутинного события, которое могло бы замаскировать новое, не случилось")
|
||||
}
|
||||
|
||||
var found bool
|
||||
for _, rec := range logRecords(t, lf.log) {
|
||||
if rec["msg"] != "delivery folded, new uncovered section" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if rec["overwrites"] == nil {
|
||||
t.Error("счётчик перезаписей исчез из записи: признаки идут атрибутами всегда")
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("запись назвала перезапись, а не новую секцию:\n%s", lf.log.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Список непокрытых секций переживает отказ разбора, то есть имя уже записано в
|
||||
// учёт. Промолчи здесь — и следующая доставка сочтёт его виденным, а событие не
|
||||
// вернётся ничем.
|
||||
func TestFoldОтказРазбораНазываетНовуюСекцию(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lf := newLoggedFold(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Слой не выводится: плотных метрик в теле нет вовсе.
|
||||
body := withSection(t, fixture(t, "sparse_sleep.json"), "cycleTracking")
|
||||
deliver(t, lf.arch, lf.st, "d1", "Default", "auto-1", body)
|
||||
if _, err := lf.svc.Fold(ctx, "d1"); err == nil {
|
||||
t.Fatal("свёртка с неопределимым слоем прошла успешно")
|
||||
}
|
||||
|
||||
var found bool
|
||||
for _, rec := range logRecords(t, lf.log) {
|
||||
for _, name := range newSections(t, rec) {
|
||||
if name == "cycleTracking" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("запись об отказе не назвала новую секцию:\n%s", lf.log.String())
|
||||
}
|
||||
|
||||
// И имя доехало до учёта — иначе терять было бы нечего.
|
||||
d, err := lf.st.LastDelivery(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение доставки: %v", err)
|
||||
}
|
||||
if !strings.Contains(d.UncoveredSections, "cycleTracking") {
|
||||
t.Errorf("список в учёте %q не содержит секции", d.UncoveredSections)
|
||||
}
|
||||
}
|
||||
|
||||
// Пересборка проигрывает журнал заново, и повторная свёртка обязана дать тот же
|
||||
// состав событий: признак новизны — функция журнала, а не числа прогонов.
|
||||
func TestFoldПовторнаяСвёрткаДаётТотЖеСоставСобытий(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lf := newLoggedFold(t)
|
||||
ctx := context.Background()
|
||||
body := withSection(t, fixture(t, "minute.json"), "symptoms")
|
||||
|
||||
deliver(t, lf.arch, lf.st, "d1", "Minutes", "auto-1", body)
|
||||
first, err := lf.svc.Fold(ctx, "d1")
|
||||
if err != nil {
|
||||
t.Fatalf("первая свёртка: %v", err)
|
||||
}
|
||||
second, err := lf.svc.Fold(ctx, "d1")
|
||||
if err != nil {
|
||||
t.Fatalf("повторная свёртка: %v", err)
|
||||
}
|
||||
|
||||
if len(first.UncoveredNew) != len(second.UncoveredNew) {
|
||||
t.Fatalf("новых секций %v против %v", first.UncoveredNew, second.UncoveredNew)
|
||||
}
|
||||
for i := range first.UncoveredNew {
|
||||
if first.UncoveredNew[i] != second.UncoveredNew[i] {
|
||||
t.Errorf("на месте %d %q против %q", i, first.UncoveredNew[i], second.UncoveredNew[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ СЛИЯНИЯ пишет имя в учёт ровно так же, как отказ разбора, — значит и
|
||||
// событие терять ему нельзя. Ветвь отдельная, и однажды она уже теряла его:
|
||||
// residue собирался вручную и новизну не нёс.
|
||||
func TestFoldОтказСлиянияНазываетНовуюСекцию(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lf := newLoggedFold(t)
|
||||
ctx := context.Background()
|
||||
|
||||
breakMerge(t, lf.dbPath)
|
||||
deliver(t, lf.arch, lf.st, "d1", "Minutes", "auto-1",
|
||||
withSection(t, fixture(t, "minute.json"), "ecg"))
|
||||
if _, err := lf.svc.Fold(ctx, "d1"); err == nil {
|
||||
t.Fatal("свёртка без таблицы объектов прошла успешно")
|
||||
}
|
||||
|
||||
var told bool
|
||||
for _, rec := range logRecords(t, lf.log) {
|
||||
for _, name := range newSections(t, rec) {
|
||||
if name == "ecg" {
|
||||
told = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !told {
|
||||
t.Errorf("запись об отказе слияния не назвала новую секцию:\n%s", lf.log.String())
|
||||
}
|
||||
|
||||
// И событие действительно потеряно быть не могло: имя уже в учёте, то есть
|
||||
// следующая доставка сочтёт его виденным.
|
||||
d, err := lf.st.LastDelivery(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("чтение доставки: %v", err)
|
||||
}
|
||||
if !strings.Contains(d.UncoveredSections, "ecg") {
|
||||
t.Errorf("список в учёте %q не содержит секции — терять было бы нечего", d.UncoveredSections)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user