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("чекпойнт на отменённом контексте прошёл успешно") } }