Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос
- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как «разобрано целиком» - каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка собрана из всего, от чего зависит ответ: версии витрины (`data_version` с закреплённого соединения плюс поколение — значение локально для соединения и не переживает переоткрытия), горизонта измерения и области действия ресурса. Версия снимается до и после сборки: снятая после пометила бы устаревший снимок свежим номером - предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший значение точки в сыром буфере записи лога
This commit is contained in:
@@ -45,3 +45,12 @@ func Transient(err error) bool {
|
||||
}
|
||||
return errors.Is(err, context.Canceled) || errors.Is(err, ErrBusy)
|
||||
}
|
||||
|
||||
// ErrClosed — хранилище закрыто, и заводить новые соединения к нему поздно.
|
||||
//
|
||||
// Отдельная ошибка, а не общая: запрос версии витрины и остановка сервиса идут
|
||||
// в разных горутинах, и запрос, успевший в это окно, обязан получить отказ, а
|
||||
// не открыть базу заново. Соединение, открытое после закрытия пула, оставило бы
|
||||
// последним себя — SQLite не сделал бы финальный чекпойнт, и рядом с базой
|
||||
// остался бы неразобранный `-wal`.
|
||||
var ErrClosed = errors.New("хранилище закрыто")
|
||||
|
||||
@@ -23,6 +23,10 @@ var migrationsFS embed.FS
|
||||
// Store — доступ к витрине.
|
||||
type Store struct {
|
||||
db *sqlx.DB
|
||||
// probe — закреплённое соединение версии витрины, заводится по первому
|
||||
// запросу версии. См. version.go: значение `data_version` локально для
|
||||
// соединения, и брать его из пула нельзя.
|
||||
probe versionProbe
|
||||
}
|
||||
|
||||
// Open открывает БД по пути, сверяет версию схемы и накатывает миграции.
|
||||
@@ -152,12 +156,32 @@ func readSchemaVersion(ctx context.Context, db *sqlx.DB) (inDB, inBinary int64,
|
||||
|
||||
// Close закрывает соединение с БД.
|
||||
func (s *Store) Close() error {
|
||||
// Щуп версии закрывается сам и раньше пула: закреплённое соединение
|
||||
// переживает `db.Close()` и продолжает отвечать на запросы — проверено.
|
||||
s.closeProbe()
|
||||
if err := s.db.Close(); err != nil {
|
||||
return fmt.Errorf("close sqlite: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// `journal_size_limit` держит верхнюю границу файла журнала. Чекпойнт
|
||||
// возвращает страницы в базу, но файл оставляет на пике: измерено — 51 МБ до и
|
||||
// после успешного переноса 12502 страниц. С лимитом первая же следующая запись
|
||||
// усекает файл до предела.
|
||||
//
|
||||
// Величина взята из чужой практики (гайды по SQLite в проде ставят 26–64 МБ) и
|
||||
// собственного измерения: суточный поток даёт около 23 МБ архива, то есть
|
||||
// журнал такого размера означает не всплеск, а удерживаемый снимок. С пределом
|
||||
// тела приёма она НЕ связана, хотя и совпадает по порядку: журнал растёт от
|
||||
// чтения, а не от размера доставки, и менять её вслед за `ingest.max_body_mb`
|
||||
// незачем.
|
||||
const journalSizeLimit = 64 << 20
|
||||
|
||||
// JournalSizeLimitMB — тот же предел для строки старта: владелец обязан видеть
|
||||
// в логе, с какими параметрами обслуживается журнал, а число живёт здесь.
|
||||
const JournalSizeLimitMB = journalSizeLimit >> 20
|
||||
|
||||
// dsn собирает строку подключения: WAL для параллельного чтения во время
|
||||
// записи, busy_timeout — чтобы конкурентная запись ждала, а не падала.
|
||||
//
|
||||
@@ -166,11 +190,14 @@ func (s *Store) Close() error {
|
||||
// запись после уже прочитанного снимка даёт SQLITE_BUSY_SNAPSHOT, которого
|
||||
// busy_timeout не покрывает. Измерено на восьми писателях: 242 успешных
|
||||
// слияния из 800 против 800 из 800.
|
||||
//
|
||||
// `journal_size_limit` — см. константу выше.
|
||||
func dsn(path string) string {
|
||||
q := url.Values{}
|
||||
q.Add("_pragma", "journal_mode(WAL)")
|
||||
q.Add("_pragma", "busy_timeout(5000)")
|
||||
q.Add("_pragma", "foreign_keys(on)")
|
||||
q.Add("_pragma", fmt.Sprintf("journal_size_limit(%d)", journalSizeLimit))
|
||||
q.Add("_txlock", "immediate")
|
||||
return "file:" + path + "?" + q.Encode()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/ident"
|
||||
)
|
||||
|
||||
// dataVersionQuery — счётчик коммитов, видимых соединению.
|
||||
//
|
||||
// SQLite меняет его, когда изменения в базу закоммитило ДРУГОЕ соединение, и
|
||||
// оставляет неизменным для коммитов самого соединения. Отсюда требование к
|
||||
// щупу: он ничего не пишет.
|
||||
const dataVersionQuery = `PRAGMA data_version`
|
||||
|
||||
// versionProbe — закреплённое соединение, с которого читается версия витрины.
|
||||
//
|
||||
// Соединение своё, а не из пула, по измеренной причине: значение `data_version`
|
||||
// локально для соединения. На одном и том же состоянии базы два соединения
|
||||
// одного пула отвечают разными числами, а любое СВЕЖЕЕ соединение отвечает
|
||||
// одним и тем же значением независимо от содержимого базы. Версия из пула
|
||||
// поэтому давала бы не только ложную инвалидацию (разные метки на
|
||||
// неизменившейся витрине — не страшно), но и одинаковые метки на разных
|
||||
// состояниях — то есть `304` на изменившиеся данные.
|
||||
//
|
||||
// Поколение выдаётся соединению и меняется вместе с ним. Без него метка не
|
||||
// переживала бы рестарт: счётчик после переоткрытия начинается заново, и одно и
|
||||
// то же значение до и после означало бы разные состояния витрины. Побочная
|
||||
// выгода: поколение меняется и при выкатке нового бинаря, так что смена ФОРМЫ
|
||||
// ответа при неизменившихся данных тоже обнуляет метки клиентов.
|
||||
//
|
||||
// Мьютекс здесь не только ради поля: `sql.Conn` не предназначен для
|
||||
// одновременного использования из нескольких горутин, а запросов чтения бывает
|
||||
// сколько угодно. Запрос при этом мгновенный — очереди на нём не образуется.
|
||||
// На щупе выполняется РОВНО ОДИН вид запроса и только через
|
||||
// `QueryRowContext(...).Scan(...)`. `QueryContext` и `BeginTx` на нём не
|
||||
// зовутся никогда: незакрытые `Rows` или открытая транзакция удержали бы
|
||||
// читающий снимок до конца жизни процесса — а щуп единственное долгоживущее
|
||||
// соединение процесса. Тогда пассивный чекпойнт перестал бы продвигаться
|
||||
// вовсе, и вторая половина задачи убила бы первую при полностью исправном
|
||||
// обслуживании. Заодно повисли бы все читающие запросы: щуп у них общий.
|
||||
type versionProbe struct {
|
||||
mu sync.Mutex
|
||||
conn *sql.Conn
|
||||
generation string
|
||||
// closed — хранилище закрыто, щупа больше не будет. Без флага гонка
|
||||
// «запрос версии против Close» воскресила бы соединение уже после закрытия
|
||||
// пула, и последнее соединение к базе осталось бы открытым: SQLite не
|
||||
// сделал бы финальный чекпойнт, а рядом с базой остался бы `-wal`.
|
||||
closed bool
|
||||
}
|
||||
|
||||
// StateVersion отдаёт версию витрины: метку, которая меняется при любом
|
||||
// коммите в базу и не меняется, пока коммитов не было.
|
||||
//
|
||||
// Имя не называет PRAGMA намеренно: метка это пара «поколение + счётчик», а не
|
||||
// голое значение `data_version`, и область её сравнимости задаёт хранилище, а
|
||||
// не SQLite. Со «версией схемы» (ErrSchemaMismatch) она не пересекается ничем.
|
||||
//
|
||||
// Равные метки означают, что между их снятием в базу никто ничего не записал, —
|
||||
// на этом и держится условный запрос читающих маршрутов. Обратное неверно:
|
||||
// метка меняется от любой записи, включая учёт доставки, витрину не менявшей.
|
||||
// Это ложная инвалидация, то есть безопасная сторона.
|
||||
func (s *Store) StateVersion(ctx context.Context) (string, error) {
|
||||
s.probe.mu.Lock()
|
||||
defer s.probe.mu.Unlock()
|
||||
|
||||
// Две попытки, а не цикл: единственная восстановимая беда — умершее
|
||||
// соединение, и лечится она ровно одним пересозданием. Повторять дальше
|
||||
// значило бы ходить по кругу за отказом, который не в соединении.
|
||||
var lastErr error
|
||||
for range 2 {
|
||||
conn, generation, err := s.probe.acquire(ctx, s.db.DB)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var counter int64
|
||||
err = conn.QueryRowContext(ctx, dataVersionQuery).Scan(&counter)
|
||||
if err == nil {
|
||||
return generation + "-" + strconv.FormatInt(counter, 10), nil
|
||||
}
|
||||
lastErr = err
|
||||
// Непригодность соединения — это ТОЛЬКО `ErrConnDone`, и список узок
|
||||
// намеренно. Измерено на этом драйвере: отмена контекста запроса щуп не
|
||||
// убивает — следующий запрос на нём проходит; непригодным соединение
|
||||
// становится после явного закрытия. Считать смертью щупа любую ошибку
|
||||
// нельзя: занятость базы и обрыв запроса клиентом (обычные события, для
|
||||
// которых в проекте заведён `Transient`) меняли бы поколение, и все
|
||||
// потребители получали бы полный ответ вместо `304` — то есть механизм
|
||||
// схлопывался бы ровно под нагрузкой, ради которой заведён.
|
||||
if !errors.Is(err, sql.ErrConnDone) {
|
||||
break
|
||||
}
|
||||
// Вместе с соединением выбрасывается и поколение: переиспользовать его
|
||||
// нельзя — счётчик у нового соединения начнётся заново, и старая метка
|
||||
// совпала бы с новой на другом состоянии.
|
||||
s.probe.release()
|
||||
}
|
||||
return "", fmt.Errorf("read data version: %w", lastErr)
|
||||
}
|
||||
|
||||
// VersionedRead выполняет чтение и отдаёт версию витрины, которой это чтение
|
||||
// подписано. Пустая версия означает «подписать нечем» — не отказ.
|
||||
//
|
||||
// Правило живёт здесь, в одном экземпляре, потому что нарушить его можно ровно
|
||||
// одним способом и этот способ опасен: версия, снятая ПОСЛЕ чтения, пометила бы
|
||||
// устаревший снимок свежей меткой и заперла бы клиента на нём навсегда. Версия,
|
||||
// снятая только ДО, допускает два разных ответа под одной меткой. Поэтому проба
|
||||
// делается дважды, а метка выдаётся, только если между пробами в базу никто не
|
||||
// коммитил.
|
||||
//
|
||||
// Read API точек и MCP заявлены потребителями той же машинерии: вторая её
|
||||
// реализация «по образцу» отличалась бы от первой ровно на этот порядок, и ни
|
||||
// один тест каталога этого не увидел бы.
|
||||
//
|
||||
// Отказ пробы версией не является и запрос не роняет: читающий маршрут
|
||||
// деградирует до полного ответа, а не до отказа. Настоящий отказ базы всплывёт
|
||||
// самим чтением, которое идёт следом, и будет назван один раз им.
|
||||
func (s *Store) VersionedRead(ctx context.Context, read func(context.Context) error) (string, error) {
|
||||
before, probeErr := s.StateVersion(ctx)
|
||||
if err := read(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if probeErr != nil {
|
||||
return "", nil
|
||||
}
|
||||
after, err := s.StateVersion(ctx)
|
||||
if err != nil || after != before {
|
||||
return "", nil
|
||||
}
|
||||
return before, nil
|
||||
}
|
||||
|
||||
// acquire отдаёт закреплённое соединение, заводя его при первом обращении.
|
||||
// Вызывается под мьютексом.
|
||||
//
|
||||
// Лениво, а не при открытии базы: щуп нужен читающим маршрутам, а `reindex` и
|
||||
// утилиты учёта открывают ту же базу и версию не спрашивают ни разу.
|
||||
func (p *versionProbe) acquire(ctx context.Context, db *sql.DB) (*sql.Conn, string, error) {
|
||||
if p.conn != nil {
|
||||
return p.conn, p.generation, nil
|
||||
}
|
||||
if p.closed {
|
||||
return nil, "", ErrClosed
|
||||
}
|
||||
conn, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("pin version probe connection: %w", err)
|
||||
}
|
||||
p.conn = conn
|
||||
p.generation = ident.NewID()
|
||||
return p.conn, p.generation, nil
|
||||
}
|
||||
|
||||
// release закрывает закреплённое соединение и забывает поколение.
|
||||
// Вызывается под мьютексом.
|
||||
func (p *versionProbe) release() {
|
||||
if p.conn == nil {
|
||||
return
|
||||
}
|
||||
// Ошибка закрытия непригодного соединения ничего не меняет: следующий
|
||||
// заход возьмёт новое.
|
||||
_ = p.conn.Close()
|
||||
p.conn = nil
|
||||
p.generation = ""
|
||||
}
|
||||
|
||||
// closeProbe закрывает щуп. Отдельно от пула и ДО него: закреплённое
|
||||
// соединение переживает `db.Close()` и продолжает отвечать на запросы
|
||||
// (проверено), то есть само по себе не закрывается ничем.
|
||||
func (s *Store) closeProbe() {
|
||||
s.probe.mu.Lock()
|
||||
defer s.probe.mu.Unlock()
|
||||
s.probe.closed = true
|
||||
s.probe.release()
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Смерть щупа — не гипотеза: `sql.Conn`, закрытый кем угодно, отвечает
|
||||
// `ErrConnDone` навсегда. Без пересоздания читающий контур остался бы без
|
||||
// условного запроса до конца жизни процесса; с пересозданием обязано смениться
|
||||
// и поколение — счётчик у нового соединения начинается заново, и старая метка
|
||||
// совпала бы с новой на другом состоянии витрины.
|
||||
func TestЩупПересоздаётсяСНовымПоколением(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("открытие базы: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
before, err := st.StateVersion(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("версия витрины: %v", err)
|
||||
}
|
||||
|
||||
// Ровно то, что делает непригодным настоящее соединение: явное закрытие.
|
||||
st.probe.mu.Lock()
|
||||
_ = st.probe.conn.Close()
|
||||
st.probe.mu.Unlock()
|
||||
|
||||
after, err := st.StateVersion(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("версия после смерти щупа: %v", err)
|
||||
}
|
||||
if generationOf(before) == generationOf(after) {
|
||||
t.Errorf("поколение переиспользовано: %q → %q", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func generationOf(version string) string {
|
||||
return strings.SplitN(version, "-", 2)[0]
|
||||
}
|
||||
|
||||
// Отказ пробы — не отказ чтения: маршрут обязан деградировать до полного
|
||||
// ответа, а не до `500`. Ветка исполняется только когда проба не удалась, а
|
||||
// чтение прошло, и другого способа туда попасть нет.
|
||||
func TestVersionedReadПриОтказеПробыОтдаётЧтениеБезВерсии(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("открытие базы: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
// Щуп закрыт, а база — нет: проба отказывает, чтение проходит.
|
||||
st.closeProbe()
|
||||
|
||||
read := false
|
||||
version, err := st.VersionedRead(t.Context(), func(context.Context) error {
|
||||
read = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("чтение подменено отказом пробы: %v", err)
|
||||
}
|
||||
if !read {
|
||||
t.Error("чтение не выполнено")
|
||||
}
|
||||
if version != "" {
|
||||
t.Errorf("ответ подписан версией %q, хотя проба отказала", version)
|
||||
}
|
||||
}
|
||||
|
||||
// Пул закрыт, а флаг «закрыто» не взведён: щупа нет и завести его нечем.
|
||||
// Отдельная ветка от ErrClosed, и она обязана быть отказом, а не пустой
|
||||
// версией — иначе отказ базы выглядел бы как «версии нет».
|
||||
func TestВерсияОтказываетКогдаПулЗакрыт(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("открытие базы: %v", err)
|
||||
}
|
||||
if err := st.db.Close(); err != nil {
|
||||
t.Fatalf("закрытие пула: %v", err)
|
||||
}
|
||||
|
||||
if _, err := st.StateVersion(t.Context()); err == nil {
|
||||
t.Error("версия снята с закрытого пула")
|
||||
}
|
||||
}
|
||||
|
||||
// Запрос версии против закрытия хранилища: ровно та гонка, ради которой заведён
|
||||
// флаг «закрыто». Проверяется под `-race`; исход законен любой, кроме
|
||||
// воскресшего соединения — его ловит требование «после Close версия отказывает».
|
||||
func TestВерсияПротивЗакрытия(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("открытие базы: %v", err)
|
||||
}
|
||||
|
||||
const n = 8
|
||||
done := make(chan struct{}, n)
|
||||
for range n {
|
||||
go func() {
|
||||
_, _ = st.StateVersion(context.Background())
|
||||
done <- struct{}{}
|
||||
}()
|
||||
}
|
||||
_ = st.Close()
|
||||
for range n {
|
||||
<-done
|
||||
}
|
||||
|
||||
if _, err := st.StateVersion(context.Background()); !errors.Is(err, ErrClosed) {
|
||||
t.Errorf("после закрытия версия отвечает %v, ждали %v", err, ErrClosed)
|
||||
}
|
||||
}
|
||||
|
||||
// Оборванный клиентом запрос — обычное событие, и смертью щупа он быть не
|
||||
// имеет права: иначе каждый такой обрыв менял бы поколение и обнулял метки всех
|
||||
// потребителей, то есть механизм схлопывался бы под нагрузкой.
|
||||
func TestОборванныйЗапросНеМеняетПоколение(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("открытие базы: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
before, err := st.StateVersion(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("версия витрины: %v", err)
|
||||
}
|
||||
|
||||
dead, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := st.StateVersion(dead); err == nil {
|
||||
t.Fatal("запрос на отменённом контексте прошёл — тест проверяет не то")
|
||||
}
|
||||
|
||||
after, err := st.StateVersion(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("версия после обрыва: %v", err)
|
||||
}
|
||||
if generationOf(before) != generationOf(after) {
|
||||
t.Errorf("обрыв запроса сменил поколение: %q → %q", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// Предел файла журнала — строка в DSN, и её пропажу не заметит ни один тест
|
||||
// поведения: журнал просто останется на пике навсегда.
|
||||
func TestПределЖурналаЗадан(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st, err := Open(filepath.Join(t.TempDir(), "healthlog.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("открытие базы: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
var limit int64
|
||||
if err := st.db.QueryRowContext(t.Context(), "PRAGMA journal_size_limit").Scan(&limit); err != nil {
|
||||
t.Fatalf("предел журнала: %v", err)
|
||||
}
|
||||
if limit != journalSizeLimit {
|
||||
t.Errorf("предел журнала %d, ждали %d", limit, journalSizeLimit)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// checkpointQuery — пассивный чекпойнт журнала WAL.
|
||||
//
|
||||
// Режим PASSIVE, а не TRUNCATE или RESTART, и причина измерена: TRUNCATE
|
||||
// двигает `data_version`, то есть каждый чекпойнт обнулял бы условный запрос у
|
||||
// всех потребителей. PASSIVE не двигает его даже перенося 12502 страницы.
|
||||
// Второй довод известнее: PASSIVE ничего не ждёт — ни читателей, ни писателей.
|
||||
const checkpointQuery = `PRAGMA wal_checkpoint(PASSIVE)`
|
||||
|
||||
// pageSizeQuery — размер страницы базы. Свойство ФАЙЛА, зафиксированное при его
|
||||
// создании, а не настройка соединения: журнал считается в страницах, а предел
|
||||
// файла назван в байтах, и без этого числа их не связать.
|
||||
const pageSizeQuery = `PRAGMA page_size`
|
||||
|
||||
// Checkpoint — исход одного чекпойнта: сколько страниц лежало в журнале и
|
||||
// сколько из них перенесено в базу.
|
||||
//
|
||||
// Отдаётся без интерпретации: решение, считать ли это бедой, принимает не
|
||||
// хранилище. Знать при этом надо обе величины, а не одну — признаком служит
|
||||
// именно их расхождение.
|
||||
type Checkpoint struct {
|
||||
// Busy — SQLite не смог взять блокировку чекпойнта. Признаком беды флаг НЕ
|
||||
// является: измерено `busy=0` при 6256 страницах в журнале и пяти
|
||||
// перенесённых — обработчик занятости в пассивном режиме не зовётся, и
|
||||
// «не продвинулись» флагом не выражается.
|
||||
//
|
||||
// Зато он выражает другое, и это обязано читаться: при `busy=1` SQLite
|
||||
// отдаёт `log = checkpointed = -1`, то есть исход НЕ ИЗМЕРЕН. Воспроизведено
|
||||
// шестью соединениями, чекпойнтящими один файл: 200 ответов `(1, -1, -1)`
|
||||
// против 40 измеренных.
|
||||
Busy bool
|
||||
// Log — страниц в журнале, Checkpointed — из них перенесено в базу.
|
||||
// Равенство означает, что журнал разобран целиком. Отрицательные значения
|
||||
// означают «не измерено» (см. Busy) и на шкале страниц не сравниваются.
|
||||
Log int
|
||||
Checkpointed int
|
||||
// PageSize — размер страницы этой базы в байтах. Ноль означает **«не
|
||||
// измерено»** и нулём не является: без него страницы не перевести в байты,
|
||||
// и признак беды молчит, а не гадает.
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// Known отвечает, измерен ли исход вообще.
|
||||
//
|
||||
// SQLite отдаёт `-1` там, где ответа нет: чекпойнт не взял блокировку либо
|
||||
// журнала не существует. Внутриполосный признак («-1 на шкале страниц») —
|
||||
// ровно тот приём, который Effective Go называет неуклюжим, и он опасен
|
||||
// буквально: `-1 >= -1` истинно, то есть незамеренный исход читался бы как
|
||||
// «журнал разобран целиком», а владельцу уходила бы строка о выздоровлении
|
||||
// посреди болезни, с числом, которого не бывает.
|
||||
func (c Checkpoint) Known() bool { return !c.Busy && c.Log >= 0 && c.Checkpointed >= 0 }
|
||||
|
||||
// Complete отвечает, разобран ли журнал целиком. Неизмеренный исход
|
||||
// разобранным не считается — «не знаем» и «разобран» разные ответы.
|
||||
//
|
||||
// Пассивный чекпойнт не идёт дальше снимка самого старого активного читателя и
|
||||
// ошибки при этом не возвращает — растущий файл единственный след. Поэтому
|
||||
// «журнал не разбирается» выражается здесь, а не флагом занятости.
|
||||
func (c Checkpoint) Complete() bool { return c.Known() && c.Checkpointed >= c.Log }
|
||||
|
||||
// Stuck отвечает, перестал ли журнал разбираться: неразобранного накопилось
|
||||
// больше, чем держит верхняя граница файла, и перенести это не вышло.
|
||||
//
|
||||
// Порог не своё число: это тот же `journal_size_limit`, только журнал считается
|
||||
// в страницах, а предел назван в байтах. Одна величина в двух ролях (предел
|
||||
// возвращает файл, порог сообщает, что вернуть его не выходит) — двумя
|
||||
// константами они разъехались бы молча, сделав признак либо недостижимым, либо
|
||||
// шумным.
|
||||
//
|
||||
// Размер страницы берётся у самой базы, а не предполагается: он фиксируется при
|
||||
// создании файла, и база, созданная чужим инструментом с другим умолчанием,
|
||||
// сместила бы порог в разы. Неизвестен — предикат молчит: гадать о пороге хуже,
|
||||
// чем не сказать.
|
||||
//
|
||||
// Предикат живёт в хранилище, а не у вызывающего: семантика тройки
|
||||
// `busy/log/checkpointed` принадлежит SQLite, и второй её экземпляр разошёлся
|
||||
// бы с первым молча.
|
||||
func (c Checkpoint) Stuck() bool {
|
||||
if !c.Known() || c.PageSize <= 0 {
|
||||
return false
|
||||
}
|
||||
return !c.Complete() && c.Log*c.PageSize >= journalSizeLimit
|
||||
}
|
||||
|
||||
// CheckpointWAL переносит страницы журнала в базу и говорит, сколько удалось.
|
||||
//
|
||||
// Автоматический чекпойнт SQLite (`wal_autocheckpoint`, 1000 страниц) остаётся
|
||||
// первой линией и отключать его незачем; этот вызов страхует случай, которого
|
||||
// автоматический не закрывает по построению — запись прекратилась, а журнал
|
||||
// остался неразобранным. Поток пачечный: ночью телефон молчит часами.
|
||||
func (s *Store) CheckpointWAL(ctx context.Context) (Checkpoint, error) {
|
||||
var busy, logPages, checkpointed int
|
||||
if err := s.db.QueryRowContext(ctx, checkpointQuery).Scan(&busy, &logPages, &checkpointed); err != nil {
|
||||
return Checkpoint{}, fmt.Errorf("wal checkpoint: %w", err)
|
||||
}
|
||||
|
||||
// Отказ ЭТОГО запроса отказом чекпойнта не является: страницы уже
|
||||
// перенесены, и объявить это провалом значило бы отправить владельца искать
|
||||
// беду в чекпойнте. Неизвестный размер страницы предикат и так трактует как
|
||||
// «не измерено» и молчит.
|
||||
var pageSize int
|
||||
if err := s.db.QueryRowContext(ctx, pageSizeQuery).Scan(&pageSize); err != nil {
|
||||
pageSize = 0
|
||||
}
|
||||
return Checkpoint{Busy: busy != 0, Log: logPages, Checkpointed: checkpointed, PageSize: pageSize}, nil
|
||||
}
|
||||
@@ -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