Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос

- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал
  пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не
  только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под
  удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а
  при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как
  «разобрано целиком»
- каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка
  собрана из всего, от чего зависит ответ: версии витрины (`data_version` с
  закреплённого соединения плюс поколение — значение локально для соединения и
  не переживает переоткрытия), горизонта измерения и области действия ресурса.
  Версия снимается до и после сборки: снятая после пометила бы устаревший снимок
  свежим номером
- предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной
  ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший
  значение точки в сыром буфере записи лога
This commit is contained in:
av
2026-08-02 20:42:22 +03:00
parent 6b729bbd2f
commit 8db2ec7ff4
37 changed files with 3575 additions and 156 deletions
+177
View File
@@ -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)
}
}