- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как «разобрано целиком» - каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка собрана из всего, от чего зависит ответ: версии витрины (`data_version` с закреплённого соединения плюс поколение — значение локально для соединения и не переживает переоткрытия), горизонта измерения и области действия ресурса. Версия снимается до и после сборки: снятая после пометила бы устаревший снимок свежим номером - предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший значение точки в сыром буфере записи лога
171 lines
6.2 KiB
Go
171 lines
6.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.vakhrushev.me/av/healthlog/internal/store"
|
|
)
|
|
|
|
// Решение «сказать ли владельцу» проверяется таблицей, а не ожиданием минут:
|
|
// состояние копится по тикам, и без отдельной точки его пришлось бы проверять
|
|
// прогоном цикла.
|
|
func TestКогдаГоворитьОНеразобранномЖурнале(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const over = 100000 // заведомо больше порога, выраженного в страницах
|
|
stuck := store.Checkpoint{Log: over, Checkpointed: 0, PageSize: 4096}
|
|
worse := store.Checkpoint{Log: over * 4, Checkpointed: 0, PageSize: 4096}
|
|
slightlyWorse := store.Checkpoint{Log: over + 1, Checkpointed: 0, PageSize: 4096}
|
|
fine := store.Checkpoint{Log: 12, Checkpointed: 12, PageSize: 4096}
|
|
// Занятый чекпойнт: исход не измерен, `-1` вместо чисел.
|
|
unknown := store.Checkpoint{Busy: true, Log: -1, Checkpointed: -1, PageSize: 4096}
|
|
|
|
var w walWatch
|
|
cases := []struct {
|
|
name string
|
|
in store.Checkpoint
|
|
want walSay
|
|
}{
|
|
{"первый застрявший чекпойнт", stuck, walStuck},
|
|
{"то же состояние — молчим", stuck, walSilent},
|
|
{"чуть хуже — всё ещё молчим", slightlyWorse, walSilent},
|
|
{"занятый тик посреди беды молчит", unknown, walSilent},
|
|
{"и не сбрасывает накопленное", stuck, walSilent},
|
|
{"стало заметно хуже", worse, walStuck},
|
|
{"разобрался — говорим о возврате", fine, walRecovered},
|
|
{"норма держится — молчим", fine, walSilent},
|
|
{"застрял снова", stuck, walStuck},
|
|
}
|
|
for _, c := range cases {
|
|
if got := w.see(c.in); got != c.want {
|
|
t.Errorf("%s: сказано %v, ждали %v", c.name, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Цикл обязан пережить отказ базы: обслуживание, умершее от временного отказа,
|
|
// молча перестало бы разбирать журнал до конца жизни процесса.
|
|
func TestЦиклЧекпойнтаПереживаетОтказ(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db"))
|
|
if err != nil {
|
|
t.Fatalf("открытие базы: %v", err)
|
|
}
|
|
// Закрытая база — самый простой источник устойчивого отказа чекпойнта.
|
|
if err := st.Close(); err != nil {
|
|
t.Fatalf("закрытие базы: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
seen := &lines{}
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
keepWAL(ctx, st, slog.New(seen), time.Millisecond)
|
|
}()
|
|
|
|
// Даём циклу натолкнуться на отказ много раз подряд.
|
|
time.Sleep(50 * time.Millisecond)
|
|
select {
|
|
case <-done:
|
|
t.Fatal("цикл вышел сам, не дождавшись отмены")
|
|
default:
|
|
}
|
|
// Отказ обязан быть виден: молча не разбирающийся журнал обнаруживается
|
|
// только по свободному месту.
|
|
if !seen.has("wal checkpoint failed") {
|
|
t.Error("отказ чекпойнта не оставил записи владельцу")
|
|
}
|
|
|
|
cancel()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("цикл не вышел по отмене")
|
|
}
|
|
}
|
|
|
|
// Отмена — единственный законный повод выйти, и выйти надо сразу: горутина
|
|
// ждётся в общем бюджете остановки вместе с воркером свёртки.
|
|
func TestЦиклЧекпойнтаВыходитПоОтмене(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db"))
|
|
if err != nil {
|
|
t.Fatalf("открытие базы: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = st.Close() })
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
keepWAL(ctx, st, slog.New(slog.DiscardHandler), time.Millisecond)
|
|
}()
|
|
|
|
cancel()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("цикл не вышел по отмене")
|
|
}
|
|
}
|
|
|
|
// Ветка «фоновые горутины не уложились в бюджет» — последняя защита инварианта
|
|
// «доставка либо свёрнута целиком, либо остаётся pending». Прогоном сервиса её
|
|
// не проверить: бюджет тридцать секунд, а заставить воркер зависнуть нечем.
|
|
func TestОжиданиеФоновыхГорутин(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
closed := make(chan struct{})
|
|
close(closed)
|
|
if !waitBackground(context.Background(), closed, slog.New(slog.DiscardHandler)) {
|
|
t.Error("вышедшие горутины не дождались")
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
seen := &lines{}
|
|
if waitBackground(ctx, make(chan struct{}), slog.New(seen)) {
|
|
t.Error("зависшие горутины объявлены вышедшими — база закрылась бы из-под них")
|
|
}
|
|
if !seen.has("shutdown budget exceeded") {
|
|
t.Error("превышение бюджета осталось без строки владельцу")
|
|
}
|
|
}
|
|
|
|
// lines — slog.Handler, копящий сообщения: проверяется факт записи, не данные.
|
|
type lines struct {
|
|
mu sync.Mutex
|
|
msg []string
|
|
}
|
|
|
|
func (l *lines) Enabled(context.Context, slog.Level) bool { return true }
|
|
|
|
func (l *lines) Handle(_ context.Context, rec slog.Record) error {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
l.msg = append(l.msg, rec.Message)
|
|
return nil
|
|
}
|
|
|
|
func (l *lines) WithAttrs([]slog.Attr) slog.Handler { return l }
|
|
func (l *lines) WithGroup(string) slog.Handler { return l }
|
|
|
|
func (l *lines) has(msg string) bool {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
for _, m := range l.msg {
|
|
if m == msg {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|