store: при равной полноте точек побеждает пришедшая доставка

- байтовый порядок канонических форм остался тай-брейком только внутри одной
  доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил
  меньшее значение, из-за чего step_count терял род и verify:archive был красным
- правило перестало быть коммутативным осознанно, поэтому порядок свёртки
  приведён к журнальному: проход воркера прекращается на отложенной доставке,
  а свёртка вне порядка журнала пишет WARN
- заведены счётчики PointsHeld и PointsErased — удержание полнотой и
  единственное направление, в котором правило теряет содержание
This commit is contained in:
av
2026-08-04 11:16:24 +03:00
parent ae607f1ceb
commit b278501a6e
44 changed files with 3780 additions and 172 deletions
+12
View File
@@ -82,6 +82,18 @@ func TestReplayЖивогоАрхива(t *testing.T) {
t.Logf("тел %d: свёрнуто %d; отказов: слой %d, содержимое %d, прочее %d; несравнимых наборов %d",
first.Bodies, first.Folded, first.FailedLayer, first.FailedMalformed,
first.FailedOther, first.Incomparable)
// Удержания печатаются, а не утверждаются: число производно от размера
// корпуса, а прогон живого архива в гейт не входит — константа покраснела бы
// молча (docs/review.md, 2026-08-02). Утверждается инвариант: удержание
// возможно только там, где сохранённая точка строго полнее, то есть их не
// может быть больше, чем столкновений вообще.
t.Logf("удержано точек правилом полноты %d, содержание унесено пришедшей %d",
first.PointsHeld, first.PointsErased)
// Посылка равенства «пересборка = приём» называется рядом с ним: отказавшие
// доставки живой путь в витрину не записал, а пересборка запишет, и
// расхождение отпечатков было бы отказом не правила, а посылки.
t.Logf("посылка сходимости: отказавших доставок %d",
first.FailedLayer+first.FailedMalformed+first.FailedOther)
t.Logf("частично разобрано %d, подобрано без учёта %d, записей без тела %d",
first.Partial, first.Adopted, first.Orphans)
+95
View File
@@ -0,0 +1,95 @@
package replay_test
import (
"context"
"database/sql"
"flag"
"log/slog"
"path/filepath"
"testing"
_ "modernc.org/sqlite" // тот же чистый Go-драйвер, что и у хранилища
"git.vakhrushev.me/av/healthlog/internal/fold"
"git.vakhrushev.me/av/healthlog/internal/replay"
)
// Прогон под удерживаемой блокировкой намеренно не входит в `task test` и
// `task gate`: `busy_timeout` — пять секунд, повторов транзакции пять, то есть
// один этот тест стоит около двадцати пяти секунд, а гейт гоняет тесты трижды.
// Запускается командой `task verify:busy`.
var runBusy = flag.Bool("healthlog.busy", false,
"прогнать свёртку под удерживаемой блокировкой базы (около 25 секунд)")
// Композиция, ради которой заведён барьер: занятость базы откладывает доставку,
// проход прекращается на ней, и живая витрина всё равно совпадает с пересборкой.
//
// Порознь это уже проверено — барьер в `worker_internal_test.go` на подменённой
// свёртке, сходимость в `order_test.go` на журнале без отложенной доставки. Но
// свойство, ради которого изменение существует, — именно композиция: тай-брейк
// «побеждает пришедшая» сделал содержимое витрины функцией порядка свёртки, и
// занятость базы это ровно тот случай, который порядок разводит. Здесь она
// НАСТОЯЩАЯ, а не подменённая: второе соединение держит запись.
func TestBusyОтложеннаяДоставкаНеРазводитПриёмИПересборку(t *testing.T) {
if !*runBusy {
t.Skip("прогон под блокировкой выключен: задайте -healthlog.busy")
}
ctx := context.Background()
dir := t.TempDir()
dbPath := filepath.Join(dir, "live.db")
arch := openArchive(t, filepath.Join(dir, "raw"))
src := openStore(t, dbPath)
log := slog.New(slog.DiscardHandler)
items := collidingJournal(t, 3)
for i := range items {
writeBody(t, arch, src, items[i], bumped(t, items[i].fixture, i))
}
w := replay.NewWorker(src, fold.New(arch, src, 0, log), log)
// Второе соединение держит запись, как её держит свёртка широкой доставки:
// измерено 11 секунд на 16 тысячах объектов, то есть окно реальное.
holder, err := sql.Open("sqlite", "file:"+dbPath+"?_pragma=busy_timeout(100)&_txlock=immediate")
if err != nil {
t.Fatalf("второе соединение: %v", err)
}
defer func() { _ = holder.Close() }()
tx, err := holder.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("удержание записи: %v", err)
}
if _, err := tx.Exec(`UPDATE delivery SET points = points`); err != nil {
t.Fatalf("удержание записи: %v", err)
}
out, err := w.Pass(ctx)
if err != nil {
t.Fatalf("проход под блокировкой: %v", err)
}
if out.Deferred != 1 {
t.Fatalf("отложенных %d, ожидалась 1: занятость не дошла до классификации, %+v", out.Deferred, out)
}
if out.Folded != 0 {
t.Fatalf("свёрнуто %d при отложенной ГОЛОВЕ очереди: барьер не сработал, %+v", out.Folded, out)
}
_ = tx.Rollback()
// Очередь пошла: те же доставки, тот же порядок журнала.
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)
}
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)
}
}
+239
View File
@@ -0,0 +1,239 @@
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)
}
}
}
}
+16 -1
View File
@@ -30,8 +30,19 @@ type Outcome struct {
// ретеншену трогать нельзя.
Partial int
// Incomparable — столкновений с несравнимыми наборами полей. На живом потоке
// их не было ни разу, и на этом стоит отказ от объединения полей.
// они единичны, и на этом стоит отказ от объединения полей.
Incomparable int
// PointsHeld — точек, удержанных правилом полноты против пришедшей
// доставки. Здесь по той же причине, что EntitiesHeld: сходимость
// отпечатка удержание не проверяет ПО ПОСТРОЕНИЮ — живой приём и пересборка
// пользуются одним правилом и одинаково сойдутся на одинаково удержанной
// точке. То есть слишком строгое правило полноты выглядело бы идеальной
// сходимостью.
PointsHeld int
// PointsErased — точек, чьё содержание унесла пришедшая победительница.
// Здесь по той же причине, что PointsHeld, и с большим весом: это
// единственное направление, в котором правило слияния теряет содержание.
PointsErased int
// EntitiesHeld — версий сущностей, удержанных правилом «не теряем
// содержания». Без него правило слияния сущностей проверить нечем:
// сходимость отпечатка его не проверяет ПО ПОСТРОЕНИЮ — живой приём и
@@ -54,6 +65,8 @@ func (o *Outcome) Add(other Outcome) {
o.FailedOther += other.FailedOther
o.Partial += other.Partial
o.Incomparable += other.Incomparable
o.PointsHeld += other.PointsHeld
o.PointsErased += other.PointsErased
o.EntitiesHeld += other.EntitiesHeld
o.EntitiesDiverging += other.EntitiesDiverging
}
@@ -118,6 +131,8 @@ func (p Player) Play(ctx context.Context, deliveryID string) (Outcome, error) {
out.Partial++
}
out.Incomparable += st.Incomparable
out.PointsHeld += st.PointsHeld
out.PointsErased += st.PointsErased
out.EntitiesHeld += st.EntitiesHeld
out.EntitiesDiverging += st.EntitiesDiverging
}
+2
View File
@@ -211,6 +211,8 @@ func Run(ctx context.Context, o Options) (Report, error) {
"failed_other", rep.FailedOther,
"partial", rep.Partial,
"incomparable", rep.Incomparable,
"points_held", rep.PointsHeld,
"points_erased", rep.PointsErased,
"entities_held", rep.EntitiesHeld,
"entities_diverging", rep.EntitiesDiverging,
"buckets", rep.Buckets,
+5 -2
View File
@@ -170,8 +170,11 @@ func TestПересборкаСовпадаетСПриёмом(t *testing.T) {
want, rep.Fingerprint)
}
// Повторный прогон в ещё одну базу обязан дать то же самое: победитель
// координаты — функция множества кандидатов, а не порядка прихода.
// Повторный прогон в ещё одну базу обязан дать то же самое: журнал тот же и
// проигрывается в том же порядке, а исход слияния — функция множества
// кандидатов вместе с их происхождением, то есть от прогона к прогону не
// плавает. От ПОРЯДКА журнала он зависит намеренно; что порядок этот
// соблюдён и живым путём, проверяет order_test.go.
again, _ := run(t, ctx, arch, src, filepath.Join(dir, "rebuild2.db"))
if again.Fingerprint != rep.Fingerprint {
t.Errorf("повторная пересборка изменила состояние:\n %s\n %s", rep.Fingerprint, again.Fingerprint)
+84 -2
View File
@@ -60,16 +60,28 @@ type Worker struct {
// ждала не воркера, а его появления, и сотня одинаковых WARN при первом же
// запуске обесценила бы уровень.
startupDone bool
// fold — свёртка одной доставки. Полем, а не прямым вызовом, ради ОДНОГО
// шва: исход `Deferred` наступает только от занятости базы, а удержать её
// по-настоящему стоит двадцать пять секунд (`busy_timeout` 5 с × 5 повторов
// транзакции) — столько же, сколько стоит `task verify:busy`, который в
// гейт намеренно не входит. Барьер журнального порядка при этом и есть
// механизм, на котором держится равенство «пересборка = приём», и оставить
// его без дешёвой проверки значило бы проверять его только тем прогоном,
// который никто не гоняет по расписанию.
fold func(ctx context.Context, deliveryID string) Outcome
}
// NewWorker собирает воркер над рабочей базой.
func NewWorker(st *store.Store, f *fold.Service, log *slog.Logger) *Worker {
return &Worker{
w := &Worker{
store: st,
player: Player{Fold: f},
log: log.With("capability", "fold-worker"),
wake: make(chan struct{}, 1),
}
w.fold = w.foldOne
return w
}
// Notify будит воркер. Вызывается приёмом после того, как доставка учтена.
@@ -169,7 +181,45 @@ func (w *Worker) Pass(ctx context.Context) (Outcome, error) {
return total, nil
}
lag.add(d)
total.Add(w.foldOne(ctx, d.ID))
// Порядок спрашивается ДО свёртки, а говорится ПОСЛЕ и только если
// свёртка состоялась: отложенная доставка витрину не трогала, и
// запись о свёртке вне порядка утверждала бы событие, которого не
// было, — да ещё повторялась бы каждым проходом, пока голова очереди
// занята. Спросить надо всё же до: после свёртки предикат уже видит
// саму эту доставку разобранной.
outOfOrder := w.outOfOrder(ctx, d)
out := w.fold(ctx, d.ID)
total.Add(out)
if outOfOrder && out.Deferred == 0 {
w.warnOutOfOrder(ctx, d)
}
if out.Deferred > 0 {
// Отложенная доставка ДЕРЖИТ очередь: перешагнув её, проход
// свернул бы её преемниц раньше неё, а тай-брейк равной полноты
// разрешается в пользу пришедшей доставки — то есть исход
// слияния стал бы функцией порядка свёртки, отличного от
// порядка журнала, и живая витрина разошлась бы с пересборкой
// молча, в значениях точек.
//
// Очередь от этого не встаёт: отложенным считается ТОЛЬКО
// занятость базы и отмена снаружи (store.Transient), а
// собственный дедлайн свёртки в него намеренно не входит —
// доставка, не уложившаяся в бюджет, получает `failed` и
// очередь освобождает. Занятость же блокирует запись всем
// одинаково: проход, перешагнувший занятую доставку, упёрся бы
// в ту же занятость на следующей.
//
// Метка отставания пишется ЗДЕСЬ, а не только на пустой
// выборке. Иначе барьер отменял бы её ровно в том состоянии,
// ради которого она заведена: занятая голова очереди не
// пропускает проход до пустой выборки никогда, и «очередь стоит
// два часа» стало бы неотличимо от «споткнулась один раз».
//
// Флаг первого прохода при этом НЕ взводится: проход до конца
// очереди не дошёл, и объявлять задолженность разобранной рано.
w.warnLag(ctx, lag)
return total, nil
}
cursor = d
}
}
@@ -216,6 +266,38 @@ func (w *Worker) foldOne(ctx context.Context, deliveryID string) Outcome {
return out
}
// warnOutOfOrder называет свёртку, идущую вне порядка журнала.
//
// Остаточное окно закрыть здесь нечем: метка `received_at` фиксируется при
// выпуске ULID, а строка учёта становится видимой только после записи тела
// (измерено 184 мс на 62 МиБ), поэтому при конкурентном приёме доставка с более
// ранней меткой появляется после того, как её преемница уже свёрнута. Барьер
// выше этого не ловит — отставшей доставки в момент прохода не существует.
//
// Молчать об этом нельзя: правило слияния точек стало функцией порядка свёртки,
// и перестановка оставляет в витрине версию не той доставки, что стоит в
// журнале последней. Лечится `healthlog reindex`; без этой строки узнать о
// необходимости неоткуда.
//
// Одна запись на доставку: событие редкое и адресное, шквала здесь не бывает.
// Отказ запроса свёртку НЕ прекращает — наблюдение не может стоить доставки.
func (w *Worker) outOfOrder(ctx context.Context, d store.PendingDelivery) bool {
later, err := w.store.ParsedAfter(ctx, d.ReceivedAt, d.ID)
if err != nil {
if ctx.Err() == nil {
w.log.ErrorContext(ctx, "journal order check failed", "error", err, "delivery_id", d.ID)
}
return false
}
return later
}
func (w *Worker) warnOutOfOrder(ctx context.Context, d store.PendingDelivery) {
w.log.WarnContext(ctx, "delivery folded out of journal order",
"delivery_id", d.ID,
"waited_sec", int64(store.Now().Sub(d.ReceivedAt).Seconds()))
}
// warnLag называет отставание одной строкой на проход.
//
// Одной, а не по строке на доставку: задолженность в сотню тел давала бы сотню
+147
View File
@@ -0,0 +1,147 @@
package replay
import (
"bytes"
"context"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// Отложенная доставка ДЕРЖИТ очередь: проход обязан прекратиться на ней, а не
// свернуть её преемниц раньше неё.
//
// Свойство это — единственное, на чём держится равенство «пересборка = приём»
// после смены тай-брейка: при равной полноте побеждает пришедшая доставка, то
// есть исход слияния стал функцией порядка свёртки. Перешагнув отложенную
// доставку, воркер оставил бы в витрине версию не той доставки, что стоит в
// журнале последней, — и расхождение было бы молчаливым.
//
// Свёртка подменяется полем, а не удерживается настоящая блокировка базы:
// `Deferred` наступает от занятости, а удержать её по-настоящему стоит
// двадцать пять секунд (`busy_timeout` 5 с × 5 повторов транзакции). Столько
// стоит `task verify:busy`, который в гейт намеренно не входит, — то есть
// проверка барьера жила бы только в прогоне, который никто не гоняет по
// расписанию.
func TestПроходПрекращаетсяНаОтложеннойДоставке(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()
base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
ids := []string{"01AAAAAAAAAAAAAAAAAAAAAAAA", "01BBBBBBBBBBBBBBBBBBBBBBBB", "01CCCCCCCCCCCCCCCCCCCCCCCC"}
for i, id := range ids {
d := store.Delivery{
ID: id,
ReceivedAt: base.Add(time.Duration(i) * time.Second),
Bytes: 1,
SHA256: "-",
RawPath: "x",
ParseStatus: store.ParsePending,
}
if err := st.CreateDelivery(ctx, d); err != nil {
t.Fatalf("учёт доставки: %v", err)
}
}
w := &Worker{
store: st,
log: slog.New(slog.DiscardHandler),
wake: make(chan struct{}, 1),
}
var seen []string
w.fold = func(_ context.Context, id string) Outcome {
seen = append(seen, id)
if id == ids[1] {
return Outcome{Deferred: 1}
}
return Outcome{Folded: 1}
}
out, err := w.Pass(ctx)
if err != nil {
t.Fatalf("проход: %v", err)
}
if len(seen) != 2 || seen[0] != ids[0] || seen[1] != ids[1] {
t.Fatalf("проход тронул %v: он обязан остановиться на второй доставке", seen)
}
if out.Folded != 1 || out.Deferred != 1 {
t.Errorf("исход прохода %+v: ожидались одна свёрнутая и одна отложенная", out)
}
// Следующий проход начинает с начала очереди и доходит до преемниц: барьер
// откладывает работу, а не отменяет её. Порядок остаётся журнальным.
//
// Первая доставка появляется в списке снова потому, что подменённая свёртка
// исхода разбора не пишет и очередь не покидает: проверяется здесь порядок,
// а не учёт.
seen = nil
w.fold = func(_ context.Context, id string) Outcome {
seen = append(seen, id)
return Outcome{Folded: 1}
}
if _, err := w.Pass(ctx); err != nil {
t.Fatalf("второй проход: %v", err)
}
if len(seen) != 3 || seen[0] != ids[0] || seen[1] != ids[1] || seen[2] != ids[2] {
t.Fatalf("второй проход тронул %v, ожидался порядок журнала %v", seen, ids)
}
}
// Метка отставания обязана срабатывать и на выходе по барьеру.
//
// Иначе барьер отменял бы её ровно в том состоянии, ради которого она заведена:
// занятая голова очереди не пропускает проход до пустой выборки НИКОГДА, и
// «очередь стоит два часа» стало бы неотличимо от «споткнулась один раз».
// Дельта capability приёма обещает это дословно: «Стоящая голова очереди
// молчать MUST NOT».
func TestМеткаОтставанияСрабатываетНаБарьере(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()
// Доставка ждёт заведомо дольше порога задержки.
at := store.Now().Add(-time.Hour)
for i, id := range []string{"01AAAAAAAAAAAAAAAAAAAAAAAA", "01BBBBBBBBBBBBBBBBBBBBBBBB"} {
d := store.Delivery{
ID: id, ReceivedAt: at.Add(time.Duration(i) * time.Second),
Bytes: 1, SHA256: "-", RawPath: "x", ParseStatus: store.ParsePending,
}
if err := st.CreateDelivery(ctx, d); err != nil {
t.Fatalf("учёт доставки: %v", err)
}
}
var buf bytes.Buffer
w := &Worker{
store: st,
log: slog.New(slog.NewJSONHandler(&buf, nil)),
wake: make(chan struct{}, 1),
}
// Первый проход уже был: метка задолженности старта подавляется намеренно.
w.startupDone = true
w.fold = func(_ context.Context, _ string) Outcome { return Outcome{Deferred: 1} }
if _, err := w.Pass(ctx); err != nil {
t.Fatalf("проход: %v", err)
}
if !strings.Contains(buf.String(), "deliveries waited for fold") {
t.Errorf("метка отставания молчит на выходе по барьеру:\n%s", buf.String())
}
}
+25
View File
@@ -98,6 +98,31 @@ func (s *logSink) count(msg string) int {
return n
}
// linesOf отдаёт записи с данным msg РАЗОБРАННЫМИ.
//
// Разобранными, а не строками: проверка «в логе нет значения точки» обязана
// смотреть поля записи, а не сырой буфер — служебное поле `time` содержит доли
// секунды, и подстрока вроде «5.1» находится в нём примерно в одном проценте
// прогонов (docs/review.md, 2026-08-02).
func (s *logSink) linesOf(msg string) []map[string]any {
s.mu.Lock()
defer s.mu.Unlock()
var out []map[string]any
for _, line := range s.lines {
if msgOf(line) != msg {
continue
}
var rec map[string]any
if err := json.Unmarshal([]byte(line), &rec); err != nil {
continue
}
delete(rec, "time")
out = append(out, rec)
}
return out
}
func (s *logSink) dump() string {
s.mu.Lock()
defer s.mu.Unlock()