- байтовый порядок канонических форм остался тай-брейком только внутри одной доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил меньшее значение, из-за чего step_count терял род и verify:archive был красным - правило перестало быть коммутативным осознанно, поэтому порядок свёртки приведён к журнальному: проход воркера прекращается на отложенной доставке, а свёртка вне порядка журнала пишет WARN - заведены счётчики PointsHeld и PointsErased — удержание полнотой и единственное направление, в котором правило теряет содержание
240 lines
12 KiB
Go
240 lines
12 KiB
Go
package replay_test
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.vakhrushev.me/av/healthlog/internal/fold"
|
|
"git.vakhrushev.me/av/healthlog/internal/ident"
|
|
"git.vakhrushev.me/av/healthlog/internal/replay"
|
|
"git.vakhrushev.me/av/healthlog/internal/store"
|
|
)
|
|
|
|
// qtyPattern находит числовые значения точек в теле HAE.
|
|
var qtyPattern = regexp.MustCompile(`("(?:qty|Avg|Min|Max)"\s*:\s*)(\d+(?:\.\d+)?)`)
|
|
|
|
// bumped берёт РЕАЛЬНЫЙ пакет HAE и сдвигает в нём числовые значения, оставляя
|
|
// координаты нетронутыми. Получается доставка, сталкивающаяся с исходной по
|
|
// всем координатам и отличающаяся от неё только значениями — то есть ровно та
|
|
// форма столкновения, ради которой изменение и делается (равные наборы полей,
|
|
// разные значения; на живом корпусе это 98,8% спорных координат).
|
|
//
|
|
// Синтетическое тело писать нельзя: разбор формата HAE проверяется на реальных
|
|
// пакетах — документация формата тонка и местами расходится с потоком. Здесь
|
|
// сохраняется и то и другое: пакет настоящий, меняются только числа.
|
|
func bumped(t *testing.T, name string, delta int) []byte {
|
|
t.Helper()
|
|
|
|
body := fixture(t, name)
|
|
out := qtyPattern.ReplaceAllFunc(body, func(m []byte) []byte {
|
|
g := qtyPattern.FindSubmatch(m)
|
|
v, err := strconv.ParseFloat(string(g[2]), 64)
|
|
if err != nil {
|
|
return m
|
|
}
|
|
return append(append([]byte{}, g[1]...), strconv.FormatFloat(v+float64(delta), 'f', -1, 64)...)
|
|
})
|
|
if string(out) == string(body) {
|
|
t.Fatalf("фикстура %s не содержит числовых значений — столкновение не построить", name)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// collidingJournal собирает журнал, где каждая следующая доставка приносит те
|
|
// же координаты с другими значениями.
|
|
func collidingJournal(t *testing.T, n int) []item {
|
|
t.Helper()
|
|
|
|
ids := make([]string, 0, n)
|
|
for range n {
|
|
ids = append(ids, ident.NewID())
|
|
}
|
|
sort.Strings(ids)
|
|
|
|
base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
|
out := make([]item, 0, n)
|
|
for i, id := range ids {
|
|
out = append(out, item{
|
|
id: id,
|
|
at: base.Add(time.Duration(i+1) * time.Second),
|
|
automationID: "auto-1",
|
|
aggregation: "Minutes",
|
|
fixture: "minute.json",
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Главный оракул задачи: тай-брейк «побеждает пришедшая» сделал содержимое
|
|
// витрины функцией ПОРЯДКА свёртки, и витрина остаётся свёрткой журнала только
|
|
// потому, что порядок свёртки приведён к порядку журнала.
|
|
//
|
|
// Проверяется на входе, воспроизводящем настоящую гонку приёма: строки учёта
|
|
// вставляются в ОБРАТНОМ хронологии порядке — так выглядит конкурентный приём,
|
|
// где поздняя доставка закоммитила строку первой. Воркер обязан всё равно
|
|
// свернуть их в порядке `(received_at, id)`, и отпечаток обязан совпасть с
|
|
// пересборкой.
|
|
//
|
|
// Проверка снабжена отрицательным контролем: те же тела, свёрнутые в обратном
|
|
// порядке, обязаны дать ДРУГОЙ отпечаток. Без него тест зеленел бы и на
|
|
// правиле, которое к порядку безразлично, — то есть не проверял бы ничего.
|
|
func TestЖиваяСвёрткаРавнаПересборкеНаСтолкновениях(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
arch := openArchive(t, filepath.Join(dir, "raw"))
|
|
src := openStore(t, filepath.Join(dir, "live.db"))
|
|
sink := newLogSink()
|
|
log := slog.New(slog.NewJSONHandler(sink, nil))
|
|
|
|
items := collidingJournal(t, 4)
|
|
bodies := make([][]byte, len(items))
|
|
for i := range items {
|
|
bodies[i] = bumped(t, items[i].fixture, i)
|
|
}
|
|
|
|
// Учёт заполняется от последней доставки к первой.
|
|
for i := len(items) - 1; i >= 0; i-- {
|
|
writeBody(t, arch, src, items[i], bodies[i])
|
|
}
|
|
|
|
w := replay.NewWorker(src, fold.New(arch, src, 0, log), log)
|
|
out, err := w.Pass(ctx)
|
|
if err != nil {
|
|
t.Fatalf("проход воркера: %v", err)
|
|
}
|
|
if out.Folded != len(items) {
|
|
t.Fatalf("свёрнуто %d из %d: %+v", out.Folded, len(items), out)
|
|
}
|
|
// Порядок журнала соблюдён, значит жаловаться не на что.
|
|
if n := sink.count("delivery folded out of journal order"); n != 0 {
|
|
t.Errorf("записей о свёртке вне порядка журнала %d, ожидалось 0:\n%s", n, sink.dump())
|
|
}
|
|
|
|
rep, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild.db"))
|
|
if got, want := rep.Fingerprint, fingerprint(t, src); got != want {
|
|
t.Errorf("живая свёртка и пересборка разошлись:\n приём %s\n пересборка %s", want, got)
|
|
}
|
|
|
|
// Отрицательный контроль: тот же журнал, свёрнутый задом наперёд.
|
|
rev := openArchive(t, filepath.Join(dir, "rev-raw"))
|
|
revStore := openStore(t, filepath.Join(dir, "rev.db"))
|
|
f := fold.New(rev, revStore, 0, slog.New(slog.DiscardHandler))
|
|
for i := len(items) - 1; i >= 0; i-- {
|
|
writeBody(t, rev, revStore, items[i], bodies[i])
|
|
if _, err := f.Fold(ctx, items[i].id); err != nil {
|
|
t.Fatalf("свёртка %s: %v", items[i].id, err)
|
|
}
|
|
}
|
|
if fingerprint(t, revStore) == rep.Fingerprint {
|
|
t.Error("обратный порядок свёртки дал тот же отпечаток: тай-брейк не зависит от порядка, то есть правило не сработало")
|
|
}
|
|
}
|
|
|
|
// Остаточное окно не молчит: доставка, ставшая видимой после того, как её
|
|
// преемница по журналу уже свёрнута, обязана оставить `WARN`.
|
|
//
|
|
// Закрыть окно здесь нечем — метка `received_at` фиксируется при выпуске ULID,
|
|
// а строка учёта появляется только после записи тела. Наблюдение и есть всё,
|
|
// что можно сделать без изменения приёма; лечится расхождение пересборкой.
|
|
func TestСвёрткаВнеПорядкаЖурналаНеМолчит(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
arch := openArchive(t, filepath.Join(dir, "raw"))
|
|
st := openStore(t, filepath.Join(dir, "live.db"))
|
|
sink := newLogSink()
|
|
log := slog.New(slog.NewJSONHandler(sink, nil))
|
|
|
|
items := collidingJournal(t, 2)
|
|
|
|
// Поздняя доставка доехала и свернулась первой — её строка учёта успела
|
|
// закоммититься, пока ранняя писала тело.
|
|
writeBody(t, arch, st, items[1], bumped(t, items[1].fixture, 1))
|
|
f := fold.New(arch, st, 0, slog.New(slog.DiscardHandler))
|
|
if _, err := f.Fold(ctx, items[1].id); err != nil {
|
|
t.Fatalf("свёртка поздней доставки: %v", err)
|
|
}
|
|
|
|
// Теперь появляется ранняя.
|
|
writeBody(t, arch, st, items[0], bumped(t, items[0].fixture, 0))
|
|
|
|
w := replay.NewWorker(st, fold.New(arch, st, 0, log), log)
|
|
if _, err := w.Pass(ctx); err != nil {
|
|
t.Fatalf("проход воркера: %v", err)
|
|
}
|
|
|
|
if n := sink.count("delivery folded out of journal order"); n != 1 {
|
|
t.Fatalf("записей о свёртке вне порядка журнала %d, ожидалась 1:\n%s", n, sink.dump())
|
|
}
|
|
// Значений точек в записи быть не должно — данные о здоровье чувствительнее
|
|
// токенов. Проверяется по разобранной записи, а не по сырому буферу: метка
|
|
// времени содержит цифры и совпала бы с любым коротким числом.
|
|
assertNoPointValues(t, sink, "delivery folded out of journal order")
|
|
}
|
|
|
|
// Доставка в статусе `failed` преемницей для этого наблюдения не считается: она
|
|
// в витрину ничего не записала, перестановка относительно неё содержимого не
|
|
// разводит, а исход этот штатный — невыводимый слой. Учитывай его предикат,
|
|
// `WARN` стал бы шумом, на который перестают смотреть.
|
|
func TestОтказавшаяПреемницаНеСчитаетсяРасхождением(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
arch := openArchive(t, filepath.Join(dir, "raw"))
|
|
st := openStore(t, filepath.Join(dir, "live.db"))
|
|
sink := newLogSink()
|
|
log := slog.New(slog.NewJSONHandler(sink, nil))
|
|
|
|
items := collidingJournal(t, 2)
|
|
// Поздняя доставка приезжает без плотных метрик и без предшественника —
|
|
// слой выводить не из чего, исход `failed`.
|
|
late := items[1]
|
|
late.aggregation = "Default"
|
|
late.automationID = "auto-lonely"
|
|
late.fixture = "sparse_sleep.json"
|
|
writeBody(t, arch, st, late, fixture(t, late.fixture))
|
|
f := fold.New(arch, st, 0, slog.New(slog.DiscardHandler))
|
|
if _, err := f.Fold(ctx, late.id); err == nil {
|
|
t.Fatal("поздняя доставка свернулась: слой не должен был вывестись")
|
|
}
|
|
if status, err := st.DeliveryStatus(ctx, late.id); err != nil || status != store.ParseFailed {
|
|
t.Fatalf("статус поздней доставки %q (%v), ожидался %q", status, err, store.ParseFailed)
|
|
}
|
|
|
|
writeBody(t, arch, st, items[0], bumped(t, items[0].fixture, 0))
|
|
w := replay.NewWorker(st, fold.New(arch, st, 0, log), log)
|
|
if _, err := w.Pass(ctx); err != nil {
|
|
t.Fatalf("проход воркера: %v", err)
|
|
}
|
|
if n := sink.count("delivery folded out of journal order"); n != 0 {
|
|
t.Errorf("записей о свёртке вне порядка журнала %d, ожидалось 0:\n%s", n, sink.dump())
|
|
}
|
|
}
|
|
|
|
// assertNoPointValues убеждается, что запись лога не несёт значений точек.
|
|
//
|
|
// Разбирает запись и выбрасывает служебные поля, а не ищет подстроку в сыром
|
|
// буфере: метка времени содержит цифры, и проверка по буферу была бы флаки по
|
|
// построению (docs/conventions/testing.md, запись 2026-08-02).
|
|
func assertNoPointValues(t *testing.T, sink *logSink, msg string) {
|
|
t.Helper()
|
|
|
|
for _, line := range sink.linesOf(msg) {
|
|
for _, k := range []string{"qty", "Avg", "Min", "Max", "value", "points"} {
|
|
if _, ok := line[k]; ok {
|
|
t.Errorf("запись %q несёт поле %q: значения точек в лог не попадают", msg, k)
|
|
}
|
|
}
|
|
}
|
|
}
|