Files
healthlog/cmd/healthlog/checkpoint_test.go
T
av 8db2ec7ff4 Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос
- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал
  пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не
  только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под
  удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а
  при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как
  «разобрано целиком»
- каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка
  собрана из всего, от чего зависит ответ: версии витрины (`data_version` с
  закреплённого соединения плюс поколение — значение локально для соединения и
  не переживает переоткрытия), горизонта измерения и области действия ресурса.
  Версия снимается до и после сборки: снятая после пометила бы устаревший снимок
  свежим номером
- предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной
  ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший
  значение точки в сыром буфере записи лога
2026-08-02 20:42:22 +03:00

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
}