Files
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

199 lines
8.5 KiB
Go

package store_test
import (
"context"
"database/sql"
"path/filepath"
"testing"
"time"
_ "modernc.org/sqlite" // второе подключение к тому же файлу мимо store
"git.vakhrushev.me/av/healthlog/internal/store"
)
func checkpoint(t *testing.T, st *store.Store) store.Checkpoint {
t.Helper()
ck, err := st.CheckpointWAL(t.Context())
if err != nil {
t.Fatalf("чекпойнт: %v", err)
}
return ck
}
// Штатный случай: писателей нет, читателей нет — журнал разбирается целиком.
// Это и есть работа, которой автоматический чекпойнт не делает: он срабатывает
// по концу записи, а ночью телефон молчит часами.
func TestЧекпойнтРазбираетЖурналВТишине(t *testing.T) {
t.Parallel()
st := open(t)
base := ts(t, "2026-06-01T00:00:00Z")
for i := range 20 {
writePoint(t, st, "step_count", base.Add(time.Duration(i)*time.Minute))
}
ck := checkpoint(t, st)
if !ck.Complete() {
t.Errorf("журнал разобран не целиком: log=%d checkpointed=%d", ck.Log, ck.Checkpointed)
}
if ck.Stuck() {
t.Errorf("разобранный журнал объявлен застрявшим: %+v", ck)
}
}
// Щуп версии — единственное долгоживущее соединение процесса, и он ходит в базу
// дважды на каждый читающий запрос. Останься за ним открытая читающая
// транзакция — пассивный чекпойнт перестал бы продвигаться навсегда, и вторая
// половина задачи убила бы первую при полностью исправном обслуживании.
func TestЩупНеУдерживаетЧитающийСнимок(t *testing.T) {
t.Parallel()
st := open(t)
base := ts(t, "2026-06-01T00:00:00Z")
for i := range 10 {
if _, err := st.StateVersion(t.Context()); err != nil {
t.Fatalf("версия витрины: %v", err)
}
writePoint(t, st, "step_count", base.Add(time.Duration(i)*time.Minute))
}
ck := checkpoint(t, st)
if !ck.Complete() {
t.Errorf("щуп удерживает снимок: log=%d checkpointed=%d", ck.Log, ck.Checkpointed)
}
}
// Удерживаемый читатель — ровно тот случай, ради которого признак и заведён:
// пассивный чекпойнт не идёт дальше его снимка и ОШИБКИ ПРИ ЭТОМ НЕ ВОЗВРАЩАЕТ.
// Проверяем, что признаком служит расхождение чисел, а не флаг занятости.
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)
}
t.Cleanup(func() { _ = st.Close() })
base := ts(t, "2026-06-01T00:00:00Z")
writePoint(t, st, "step_count", base)
// Читающая транзакция мимо store: она моделирует не наш код, а любого
// читателя, задержавшегося на снимке, — включая забытый `rows.Close()`.
db, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)")
if err != nil {
t.Fatalf("открытие второго подключения: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
tx, err := db.BeginTx(t.Context(), &sql.TxOptions{ReadOnly: true})
if err != nil {
t.Fatalf("читающая транзакция: %v", err)
}
var n int
if err := tx.QueryRowContext(t.Context(), "SELECT count(*) FROM bucket").Scan(&n); err != nil {
t.Fatalf("чтение снимка: %v", err)
}
for i := 1; i < 40; i++ {
writePoint(t, st, "step_count", base.Add(time.Duration(i)*time.Minute))
}
ck := checkpoint(t, st)
_ = tx.Rollback()
if ck.Busy {
t.Errorf("флаг занятости взведён — признак строится не на нём: %+v", ck)
}
if ck.Complete() {
// Не Skip: на платформе, разбирающей журнал под удерживаемым читателем,
// ломается предпосылка всей задачи, а пропущенный тест выглядит зелёным
// — и вместе с ним молча исчезает единственная защита режима PASSIVE.
t.Fatalf("журнал разобран под удерживаемым читателем — предпосылка задачи не воспроизводится: %+v", ck)
}
if ck.Log <= ck.Checkpointed {
t.Errorf("перенесено не меньше, чем лежит: %+v", ck)
}
}
// Порог молчит на журнале обычного размера: иначе `WARN` шёл бы каждую минуту
// на здоровом сервисе, и уровень, по которому вмешиваются, перестал бы значить
// что-либо.
func TestНебольшойНеразобранныйЖурналНеЗастрял(t *testing.T) {
t.Parallel()
ck := store.Checkpoint{Log: 10, Checkpointed: 0, PageSize: 4096}
if ck.Stuck() {
t.Errorf("десять неразобранных страниц объявлены бедой: %+v", ck)
}
if ck.Complete() {
t.Errorf("неразобранный журнал объявлен разобранным: %+v", ck)
}
}
// Занятый чекпойнт отдаёт `busy=1` и `-1` вместо чисел: исход НЕ ИЗМЕРЕН.
// Внутриполосный `-1` опасен буквально — `-1 >= -1` истинно, то есть
// незамеренный тик читался бы как «журнал разобран целиком», и владельцу ушла
// бы строка о выздоровлении посреди болезни.
func TestЗанятыйЧекпойнтНеИзмерен(t *testing.T) {
t.Parallel()
ck := store.Checkpoint{Busy: true, Log: -1, Checkpointed: -1, PageSize: 4096}
if ck.Known() {
t.Errorf("занятый чекпойнт объявлен измеренным: %+v", ck)
}
if ck.Complete() {
t.Errorf("незамеренный исход объявлен разобранным журналом: %+v", ck)
}
if ck.Stuck() {
t.Errorf("незамеренный исход объявлен бедой: %+v", ck)
}
}
// Журнала нет вовсе — SQLite отвечает теми же `-1`. Исход тот же: молчим.
func TestОтсутствующийЖурналНеИзмерен(t *testing.T) {
t.Parallel()
ck := store.Checkpoint{Log: -1, Checkpointed: -1, PageSize: 4096}
if ck.Known() || ck.Complete() || ck.Stuck() {
t.Errorf("исход без журнала прочитан как измеренный: %+v", ck)
}
}
// Размер страницы — свойство файла, и без него порог не выразить. Неизвестен —
// признак молчит: сместившийся в разы порог хуже, чем его отсутствие.
func TestБезРазмераСтраницыПризнакМолчит(t *testing.T) {
t.Parallel()
ck := store.Checkpoint{Log: 1 << 20, Checkpointed: 0}
if ck.Stuck() {
t.Errorf("порог сработал при неизвестном размере страницы: %+v", ck)
}
}
// Размер страницы приходит из базы, а не предполагается кодом.
func TestЧекпойнтНазываетРазмерСтраницы(t *testing.T) {
t.Parallel()
if got := checkpoint(t, open(t)).PageSize; got <= 0 {
t.Errorf("размер страницы %d — порог выразить нечем", got)
}
}
// Отмена контекста не должна превращаться в отказ обслуживания: цикл проверяет
// её сам, а вызов обязан вернуть ошибку, а не молчаливый нулевой исход.
func TestЧекпойнтНаОтменённомКонтексте(t *testing.T) {
t.Parallel()
st := open(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := st.CheckpointWAL(ctx); err == nil {
t.Error("чекпойнт на отменённом контексте прошёл успешно")
}
}