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
+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())
}
}