Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос
- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как «разобрано целиком» - каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка собрана из всего, от чего зависит ответ: версии витрины (`data_version` с закреплённого соединения плюс поколение — значение локально для соединения и не переживает переоткрытия), горизонта измерения и области действия ресурса. Версия снимается до и после сборки: снятая после пометила бы устаревший снимок свежим номером - предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший значение точки в сыром буфере записи лога
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
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("чекпойнт на отменённом контексте прошёл успешно")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user