Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос
- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как «разобрано целиком» - каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка собрана из всего, от чего зависит ответ: версии витрины (`data_version` с закреплённого соединения плюс поколение — значение локально для соединения и не переживает переоткрытия), горизонта измерения и области действия ресурса. Версия снимается до и после сборки: снятая после пометила бы устаревший снимок свежим номером - предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший значение точки в сыром буфере записи лога
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
func version(t *testing.T, st *store.Store) string {
|
||||
t.Helper()
|
||||
|
||||
v, err := st.StateVersion(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("версия витрины: %v", err)
|
||||
}
|
||||
if v == "" {
|
||||
t.Fatal("версия витрины пуста")
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Точка в витрину, чтобы у версии было от чего измениться.
|
||||
func writePoint(t *testing.T, st *store.Store, metric string, at time.Time) {
|
||||
t.Helper()
|
||||
|
||||
_, err := st.Merge(context.Background(), store.Incoming{Points: []store.IncomingPoint{{
|
||||
Metric: metric, Layer: "minute", Units: "count",
|
||||
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":1}`)},
|
||||
}}}, store.DeliveryRef{ID: "d"})
|
||||
if err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Главное свойство: без коммитов метка не двигается. На нём держится `304`.
|
||||
func TestВерсияНеМеняетсяБезЗаписи(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
first := version(t, st)
|
||||
|
||||
// Читающие запросы версию двигать не имеют права: иначе условный запрос не
|
||||
// сработал бы ни разу — каждый ответ каталога сам бы себя и обесценивал.
|
||||
if _, err := st.ReadCatalog(t.Context(), window(48, time.Now())); err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
if got := version(t, st); got != first {
|
||||
t.Errorf("версия сдвинулась без записи: %q → %q", first, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Запись из пула — то есть с ЧУЖОГО для щупа соединения — обязана быть видна.
|
||||
// Это ровно случай фоновой свёртки: она пишет, пока читающий маршрут отвечает.
|
||||
func TestВерсияМеняетсяПослеЗаписи(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
before := version(t, st)
|
||||
|
||||
writePoint(t, st, "step_count", ts(t, "2026-06-01T10:00:00Z"))
|
||||
|
||||
after := version(t, st)
|
||||
if after == before {
|
||||
t.Errorf("версия не изменилась после записи: %q", before)
|
||||
}
|
||||
if strings.SplitN(before, "-", 2)[0] != strings.SplitN(after, "-", 2)[0] {
|
||||
t.Errorf("поколение сменилось без пересоздания щупа: %q → %q", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// Счётчик `data_version` после переоткрытия базы начинается заново, и одно и то
|
||||
// же значение до и после означало бы разные состояния витрины. Метку от этого
|
||||
// спасает поколение — проверяем на одном и том же файле.
|
||||
func TestВерсияНеПовторяетсяПослеПереоткрытия(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "healthlog.db")
|
||||
|
||||
first, err := store.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("открытие базы: %v", err)
|
||||
}
|
||||
before := version(t, first)
|
||||
writePoint(t, first, "step_count", ts(t, "2026-06-01T10:00:00Z"))
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatalf("закрытие базы: %v", err)
|
||||
}
|
||||
|
||||
second, err := store.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("повторное открытие базы: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = second.Close() })
|
||||
|
||||
if after := version(t, second); after == before {
|
||||
t.Errorf("версия совпала через переоткрытие: %q — клиент получил бы 304 на изменившиеся данные", before)
|
||||
}
|
||||
}
|
||||
|
||||
// Версия читается конкурентно: `sql.Conn` одновременного использования не
|
||||
// допускает, и без защиты это гонка, а не редкий отказ.
|
||||
func TestВерсияЧитаетсяКонкурентно(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
const n = 16
|
||||
errs := make(chan error, n)
|
||||
for range n {
|
||||
go func() {
|
||||
_, err := st.StateVersion(context.Background())
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
for range n {
|
||||
if err := <-errs; err != nil {
|
||||
t.Fatalf("версия витрины: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Подпись ответа: пока витрина стоит, чтение подписывается версией.
|
||||
func TestVersionedReadПодписываетТихоеЧтение(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
called := false
|
||||
v, err := st.VersionedRead(t.Context(), func(context.Context) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("чтение с версией: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("чтение не выполнено")
|
||||
}
|
||||
if v == "" {
|
||||
t.Error("тихое чтение осталось без версии")
|
||||
}
|
||||
}
|
||||
|
||||
// Единственная ветка, ради которой проба делается дважды: витрина изменилась,
|
||||
// пока ответ собирался. Метки быть не должно — иначе два разных ответа уехали
|
||||
// бы под одной, а подписать снимок версией, снятой ПОСЛЕ него, значило бы
|
||||
// запереть клиента на устаревшем ответе навсегда.
|
||||
func TestVersionedReadНеПодписываетИзменившеесяЧтение(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
v, err := st.VersionedRead(t.Context(), func(ctx context.Context) error {
|
||||
writePoint(t, st, "step_count", ts(t, "2026-06-01T10:00:00Z"))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("чтение с версией: %v", err)
|
||||
}
|
||||
if v != "" {
|
||||
t.Errorf("ответ подписан версией %q, хотя витрина изменилась при сборке", v)
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ самого чтения версией не подменяется: это отказ операции, и он обязан
|
||||
// дойти до вызывающего.
|
||||
func TestVersionedReadВозвращаетОтказЧтения(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := open(t)
|
||||
want := errors.New("чтение не вышло")
|
||||
if _, err := st.VersionedRead(t.Context(), func(context.Context) error { return want }); !errors.Is(err, want) {
|
||||
t.Errorf("ошибка %v, ждали %v", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
// После закрытия хранилища щуп не воскресает: соединение, открытое позже
|
||||
// закрытия пула, осталось бы последним, и SQLite не сделал бы финальный
|
||||
// чекпойнт.
|
||||
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)
|
||||
}
|
||||
if _, err := st.StateVersion(context.Background()); !errors.Is(err, store.ErrClosed) {
|
||||
t.Errorf("ошибка %v, ждали %v", err, store.ErrClosed)
|
||||
}
|
||||
}
|
||||
|
||||
// Закрытие хранилища обязано оставить базу без журнала: финальный чекпойнт
|
||||
// делает SQLite при закрытии ПОСЛЕДНЕГО соединения, а щуп его переживает.
|
||||
// Незакрытый щуп означал бы `-wal` рядом с базой — и подмену базы пересборкой
|
||||
// без хвоста записей.
|
||||
func TestЗакрытиеНеОставляетЖурнала(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "healthlog.db")
|
||||
st, err := store.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("открытие базы: %v", err)
|
||||
}
|
||||
if _, err := st.StateVersion(t.Context()); err != nil {
|
||||
t.Fatalf("версия витрины: %v", err)
|
||||
}
|
||||
writePoint(t, st, "step_count", ts(t, "2026-06-01T10:00:00Z"))
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("закрытие базы: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(path + "-wal"); !os.IsNotExist(err) {
|
||||
t.Errorf("рядом с базой остался журнал: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user