- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как «разобрано целиком» - каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка собрана из всего, от чего зависит ответ: версии витрины (`data_version` с закреплённого соединения плюс поколение — значение локально для соединения и не переживает переоткрытия), горизонта измерения и области действия ресурса. Версия снимается до и после сборки: снятая после пометила бы устаревший снимок свежим номером - предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший значение точки в сыром буфере записи лога
199 lines
8.5 KiB
Go
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("чекпойнт на отменённом контексте прошёл успешно")
|
|
}
|
|
}
|