- байтовый порядок канонических форм остался тай-брейком только внутри одной доставки: на живом корпусе он решал 98,8% спорных координат и системно хранил меньшее значение, из-за чего step_count терял род и verify:archive был красным - правило перестало быть коммутативным осознанно, поэтому порядок свёртки приведён к журнальному: проход воркера прекращается на отложенной доставке, а свёртка вне порядка журнала пишет WARN - заведены счётчики PointsHeld и PointsErased — удержание полнотой и единственное направление, в котором правило теряет содержание
148 lines
6.2 KiB
Go
148 lines
6.2 KiB
Go
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())
|
||
}
|
||
}
|