Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос
- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как «разобрано целиком» - каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка собрана из всего, от чего зависит ответ: версии витрины (`data_version` с закреплённого соединения плюс поколение — значение локально для соединения и не переживает переоткрытия), горизонта измерения и области действия ресурса. Версия снимается до и после сборки: снятая после пометила бы устаревший снимок свежим номером - предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший значение точки в сыром буфере записи лога
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Смерть щупа — не гипотеза: `sql.Conn`, закрытый кем угодно, отвечает
|
||||
// `ErrConnDone` навсегда. Без пересоздания читающий контур остался бы без
|
||||
// условного запроса до конца жизни процесса; с пересозданием обязано смениться
|
||||
// и поколение — счётчик у нового соединения начинается заново, и старая метка
|
||||
// совпала бы с новой на другом состоянии витрины.
|
||||
func TestЩупПересоздаётсяСНовымПоколением(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("открытие базы: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
before, err := st.StateVersion(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("версия витрины: %v", err)
|
||||
}
|
||||
|
||||
// Ровно то, что делает непригодным настоящее соединение: явное закрытие.
|
||||
st.probe.mu.Lock()
|
||||
_ = st.probe.conn.Close()
|
||||
st.probe.mu.Unlock()
|
||||
|
||||
after, err := st.StateVersion(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("версия после смерти щупа: %v", err)
|
||||
}
|
||||
if generationOf(before) == generationOf(after) {
|
||||
t.Errorf("поколение переиспользовано: %q → %q", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func generationOf(version string) string {
|
||||
return strings.SplitN(version, "-", 2)[0]
|
||||
}
|
||||
|
||||
// Отказ пробы — не отказ чтения: маршрут обязан деградировать до полного
|
||||
// ответа, а не до `500`. Ветка исполняется только когда проба не удалась, а
|
||||
// чтение прошло, и другого способа туда попасть нет.
|
||||
func TestVersionedReadПриОтказеПробыОтдаётЧтениеБезВерсии(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("открытие базы: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
// Щуп закрыт, а база — нет: проба отказывает, чтение проходит.
|
||||
st.closeProbe()
|
||||
|
||||
read := false
|
||||
version, err := st.VersionedRead(t.Context(), func(context.Context) error {
|
||||
read = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("чтение подменено отказом пробы: %v", err)
|
||||
}
|
||||
if !read {
|
||||
t.Error("чтение не выполнено")
|
||||
}
|
||||
if version != "" {
|
||||
t.Errorf("ответ подписан версией %q, хотя проба отказала", version)
|
||||
}
|
||||
}
|
||||
|
||||
// Пул закрыт, а флаг «закрыто» не взведён: щупа нет и завести его нечем.
|
||||
// Отдельная ветка от ErrClosed, и она обязана быть отказом, а не пустой
|
||||
// версией — иначе отказ базы выглядел бы как «версии нет».
|
||||
func TestВерсияОтказываетКогдаПулЗакрыт(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("открытие базы: %v", err)
|
||||
}
|
||||
if err := st.db.Close(); err != nil {
|
||||
t.Fatalf("закрытие пула: %v", err)
|
||||
}
|
||||
|
||||
if _, err := st.StateVersion(t.Context()); err == nil {
|
||||
t.Error("версия снята с закрытого пула")
|
||||
}
|
||||
}
|
||||
|
||||
// Запрос версии против закрытия хранилища: ровно та гонка, ради которой заведён
|
||||
// флаг «закрыто». Проверяется под `-race`; исход законен любой, кроме
|
||||
// воскресшего соединения — его ловит требование «после Close версия отказывает».
|
||||
func TestВерсияПротивЗакрытия(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("открытие базы: %v", err)
|
||||
}
|
||||
|
||||
const n = 8
|
||||
done := make(chan struct{}, n)
|
||||
for range n {
|
||||
go func() {
|
||||
_, _ = st.StateVersion(context.Background())
|
||||
done <- struct{}{}
|
||||
}()
|
||||
}
|
||||
_ = st.Close()
|
||||
for range n {
|
||||
<-done
|
||||
}
|
||||
|
||||
if _, err := st.StateVersion(context.Background()); !errors.Is(err, ErrClosed) {
|
||||
t.Errorf("после закрытия версия отвечает %v, ждали %v", err, ErrClosed)
|
||||
}
|
||||
}
|
||||
|
||||
// Оборванный клиентом запрос — обычное событие, и смертью щупа он быть не
|
||||
// имеет права: иначе каждый такой обрыв менял бы поколение и обнулял метки всех
|
||||
// потребителей, то есть механизм схлопывался бы под нагрузкой.
|
||||
func TestОборванныйЗапросНеМеняетПоколение(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("открытие базы: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
before, err := st.StateVersion(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("версия витрины: %v", err)
|
||||
}
|
||||
|
||||
dead, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := st.StateVersion(dead); err == nil {
|
||||
t.Fatal("запрос на отменённом контексте прошёл — тест проверяет не то")
|
||||
}
|
||||
|
||||
after, err := st.StateVersion(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("версия после обрыва: %v", err)
|
||||
}
|
||||
if generationOf(before) != generationOf(after) {
|
||||
t.Errorf("обрыв запроса сменил поколение: %q → %q", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// Предел файла журнала — строка в DSN, и её пропажу не заметит ни один тест
|
||||
// поведения: журнал просто останется на пике навсегда.
|
||||
func TestПределЖурналаЗадан(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("открытие базы: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
var limit int64
|
||||
if err := st.db.QueryRowContext(t.Context(), "PRAGMA journal_size_limit").Scan(&limit); err != nil {
|
||||
t.Fatalf("предел журнала: %v", err)
|
||||
}
|
||||
if limit != journalSizeLimit {
|
||||
t.Errorf("предел журнала %d, ждали %d", limit, journalSizeLimit)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user