Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос
- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как «разобрано целиком» - каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка собрана из всего, от чего зависит ответ: версии витрины (`data_version` с закреплённого соединения плюс поколение — значение локально для соединения и не переживает переоткрытия), горизонта измерения и области действия ресурса. Версия снимается до и после сборки: снятая после пометила бы устаревший снимок свежим номером - предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший значение точки в сыром буфере записи лога
This commit is contained in:
+75
-11
@@ -204,6 +204,54 @@ type Metric struct {
|
||||
Layers []LayerRange `json:"layers"`
|
||||
}
|
||||
|
||||
// Snapshot — каталог вместе с версией ответа.
|
||||
//
|
||||
// Версия пустая, когда подписать ответ нечем: витрина изменилась, пока он
|
||||
// собирался, или прочитать её версию не удалось. Это не отказ — ответ уходит
|
||||
// целиком, просто без условной метки, ровно как до появления условного запроса.
|
||||
//
|
||||
// Имя перекликается с `store.CatalogSnapshot` намеренно и означает другое: тот
|
||||
// снимок — вход измерения (объекты и разрезы), этот — готовый ответ.
|
||||
type Snapshot struct {
|
||||
Version string
|
||||
Metrics []Metric
|
||||
}
|
||||
|
||||
// Version — версия ОТВЕТА каталога: версия витрины плюс горизонт измерения.
|
||||
//
|
||||
// Горизонт входит в неё, потому что ответ есть функция обоих. Час объекта
|
||||
// сравнивается с `Now() + horizonSlack`, и с ходом часов состав окна меняется
|
||||
// без единого коммита: метка из будущего, лежащая в витрине (сбитые часы
|
||||
// телефона — состояние, о котором рядом пишется WARN), въезжает в окно сама и
|
||||
// способна перевернуть измеренный род. Построено и прогнано: та же версия
|
||||
// витрины, `cumulative` против `unknown`, ноль коммитов между.
|
||||
//
|
||||
// Огрубление до часа не приблизительное, а точное: `hour_utc` объектов лежит
|
||||
// ровно на часах, поэтому отбор `hour_utc <= горизонт` меняется ровно при
|
||||
// переходе горизонта через час. Цена — один полный ответ в час на потребителя
|
||||
// при неизменившейся витрине; сборка каталога на живом корпусе стоит 45 мс.
|
||||
func (s *Service) Version(ctx context.Context) (string, error) {
|
||||
version, err := s.store.StateVersion(ctx)
|
||||
if err != nil {
|
||||
// Отказ пробы отказом маршрута не является — но и молчать о нём нельзя:
|
||||
// без метки условный запрос выключается для всех потребителей, а
|
||||
// снаружи это неотличимо от нормы. DEBUG, потому что адресат здесь
|
||||
// разработчик: владельцу об этом скажет `/stats`, когда появится.
|
||||
s.log.DebugContext(ctx, "state version unavailable", "capability", "query", "error", err)
|
||||
return "", err
|
||||
}
|
||||
return stamp(version, store.Now().Add(horizonSlack)), nil
|
||||
}
|
||||
|
||||
// stamp склеивает версию витрины с горизонтом. Пустая версия остаётся пустой:
|
||||
// подписывать нечем — значит нечем, и горизонт этого не меняет.
|
||||
func stamp(version string, horizon time.Time) string {
|
||||
if version == "" {
|
||||
return ""
|
||||
}
|
||||
return version + "." + horizon.Truncate(time.Hour).Format("2006010215")
|
||||
}
|
||||
|
||||
// Service собирает каталог по витрине.
|
||||
type Service struct {
|
||||
store *store.Store
|
||||
@@ -223,17 +271,26 @@ func New(st *store.Store, log *slog.Logger) *Service {
|
||||
// и объяснять, на каком составе данных оно снято; устаревшее при этом выглядит
|
||||
// ровно как свежее. Вычисленный на запрос род есть функция витрины, а витрина —
|
||||
// функция журнала, и устаревать в нём нечему.
|
||||
func (s *Service) Metrics(ctx context.Context) ([]Metric, error) {
|
||||
// Ответ подписывается версией витрины: она нужна условному запросу, и снимает
|
||||
// её хранилище — двумя пробами вокруг чтения. Порядок проб там же и объяснён:
|
||||
// версия, снятая после чтения, пометила бы устаревший снимок свежей меткой.
|
||||
func (s *Service) Metrics(ctx context.Context) (Snapshot, error) {
|
||||
horizon := store.Now().Add(horizonSlack)
|
||||
snap, err := s.store.ReadCatalog(ctx, store.CatalogWindow{
|
||||
Fine: string(hae.LayerMinute),
|
||||
Coarse: string(hae.LayerHour),
|
||||
Hours: Window,
|
||||
Horizon: horizon,
|
||||
CoarsePoints: coarsePoints,
|
||||
MinFinePoints: minFinePoints,
|
||||
|
||||
var snap store.CatalogSnapshot
|
||||
version, err := s.store.VersionedRead(ctx, func(ctx context.Context) error {
|
||||
var err error
|
||||
snap, err = s.store.ReadCatalog(ctx, store.CatalogWindow{
|
||||
Fine: string(hae.LayerMinute),
|
||||
Coarse: string(hae.LayerHour),
|
||||
Hours: Window,
|
||||
Horizon: horizon,
|
||||
CoarsePoints: coarsePoints,
|
||||
MinFinePoints: minFinePoints,
|
||||
})
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
if err != nil { //nolint:nestif // ветка одна, вложенность даёт лог по адресату
|
||||
// Единственный логирующий чекпоинт исхода: транспорт переводит ошибку в
|
||||
// ответ и второй раз её не пишет.
|
||||
//
|
||||
@@ -247,7 +304,7 @@ func (s *Service) Metrics(ctx context.Context) ([]Metric, error) {
|
||||
} else {
|
||||
s.log.ErrorContext(ctx, "catalog failed", "capability", "query", "error", err)
|
||||
}
|
||||
return nil, err
|
||||
return Snapshot{}, err
|
||||
}
|
||||
|
||||
out := make([]Metric, 0, len(snap.Layers))
|
||||
@@ -286,7 +343,14 @@ func (s *Service) Metrics(ctx context.Context) ([]Metric, error) {
|
||||
Layers: group.layers,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
if version == "" {
|
||||
// Витрина изменилась, пока ответ собирался (или версию не прочитать).
|
||||
// Ответ уйдёт без метки — это безопасная сторона, но след нужен: под
|
||||
// плотным потоком доставок так может уходить каждый ответ, и тогда
|
||||
// механизм не окупается вовсе.
|
||||
s.log.DebugContext(ctx, "catalog unsigned", "capability", "query")
|
||||
}
|
||||
return Snapshot{Version: stamp(version, horizon), Metrics: out}, nil
|
||||
}
|
||||
|
||||
type metricGroup struct {
|
||||
|
||||
@@ -129,7 +129,7 @@ func TestКаталогОтдаётРазрезыИИзмеренныйРод(t
|
||||
incoming("sleep_analysis", "raw", "hr", time.Date(2026, 6, 1, 3, 7, 0, 0, time.UTC), 1),
|
||||
})
|
||||
|
||||
metrics, err := service(t, st).Metrics(context.Background())
|
||||
metrics, err := metricsOf(context.Background(), service(t, st))
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
@@ -179,7 +179,7 @@ func TestКаталогНеИзмеряетПоНижнемуСлою(t *testing
|
||||
}
|
||||
merge(t, st, points)
|
||||
|
||||
metrics, err := service(t, st).Metrics(context.Background())
|
||||
metrics, err := metricsOf(context.Background(), service(t, st))
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
@@ -198,7 +198,7 @@ func TestКаталогОграничиваетОкно(t *testing.T) {
|
||||
st := openStore(t)
|
||||
fill(t, st, "step_count", catalog.Window+7, true)
|
||||
|
||||
metrics, err := service(t, st).Metrics(context.Background())
|
||||
metrics, err := metricsOf(context.Background(), service(t, st))
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
@@ -227,7 +227,7 @@ func TestКаталогПоказываетРасхождениеЕдиниц(t
|
||||
incoming("walking_running_distance", "minute", "m", base.Add(2*time.Hour), 2),
|
||||
})
|
||||
|
||||
metrics, err := service(t, st).Metrics(context.Background())
|
||||
metrics, err := metricsOf(context.Background(), service(t, st))
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
@@ -249,7 +249,7 @@ func TestКаталогПоказываетРасхождениеЕдиниц(t
|
||||
func TestКаталогПустойВитриныПуст(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
metrics, err := service(t, openStore(t)).Metrics(context.Background())
|
||||
metrics, err := metricsOf(context.Background(), service(t, openStore(t)))
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
@@ -268,7 +268,7 @@ func TestКаталогПересчитываетРодНаКаждыйЗапр
|
||||
ctx := context.Background()
|
||||
|
||||
fill(t, st, "step_count", 2, true)
|
||||
before, err := svc.Metrics(ctx)
|
||||
before, err := metricsOf(ctx, svc)
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
@@ -277,7 +277,7 @@ func TestКаталогПересчитываетРодНаКаждыйЗапр
|
||||
}
|
||||
|
||||
fill(t, st, "step_count", 5, true)
|
||||
after, err := svc.Metrics(ctx)
|
||||
after, err := metricsOf(ctx, svc)
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
@@ -310,7 +310,7 @@ func TestКаталогНеИзмеряетПоБудущимЧасам(t *testi
|
||||
merge(t, st, future)
|
||||
|
||||
handler := &logged{}
|
||||
metrics, err := catalog.New(st, slog.New(handler)).Metrics(context.Background())
|
||||
metrics, err := metricsOf(context.Background(), catalog.New(st, slog.New(handler)))
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
@@ -344,7 +344,7 @@ func TestКаталогНеСверяетСлоиРазныхЕдиниц(t *tes
|
||||
}
|
||||
merge(t, st, points)
|
||||
|
||||
metrics, err := service(t, st).Metrics(context.Background())
|
||||
metrics, err := metricsOf(context.Background(), service(t, st))
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
@@ -369,7 +369,7 @@ func TestКаталогПоказываетМетрикуСПустымИмен
|
||||
incoming("step_count", "minute", "count", base, 2),
|
||||
})
|
||||
|
||||
metrics, err := service(t, st).Metrics(context.Background())
|
||||
metrics, err := metricsOf(context.Background(), service(t, st))
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
@@ -402,7 +402,7 @@ func TestКаталогПишетПредупреждениеОПротивор
|
||||
merge(t, st, points)
|
||||
|
||||
handler := &logged{}
|
||||
metrics, err := catalog.New(st, slog.New(handler)).Metrics(context.Background())
|
||||
metrics, err := metricsOf(context.Background(), catalog.New(st, slog.New(handler)))
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
@@ -439,7 +439,7 @@ func TestКаталогОграничиваетЧислоЕдиниц(t *testing
|
||||
}
|
||||
merge(t, st, points)
|
||||
|
||||
metrics, err := service(t, st).Metrics(context.Background())
|
||||
metrics, err := metricsOf(context.Background(), service(t, st))
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
@@ -463,7 +463,7 @@ func TestКаталогСообщаетОбОтказеХранилища(t *tes
|
||||
}
|
||||
|
||||
handler := &logged{}
|
||||
if _, err := catalog.New(st, slog.New(handler)).Metrics(context.Background()); err == nil {
|
||||
if _, err := metricsOf(context.Background(), catalog.New(st, slog.New(handler))); err == nil {
|
||||
t.Fatal("каталог на закрытой базе собрался")
|
||||
}
|
||||
rec, ok := handler.find("catalog failed")
|
||||
@@ -488,7 +488,7 @@ func TestКаталогНеПутаетОтменуСоСбоем(t *testing.T)
|
||||
cancel()
|
||||
|
||||
handler := &logged{}
|
||||
if _, err := catalog.New(st, slog.New(handler)).Metrics(ctx); err == nil {
|
||||
if _, err := metricsOf(ctx, catalog.New(st, slog.New(handler))); err == nil {
|
||||
t.Fatal("каталог собрался на отменённом контексте")
|
||||
}
|
||||
if _, ok := handler.find("catalog failed"); ok {
|
||||
@@ -498,3 +498,25 @@ func TestКаталогНеПутаетОтменуСоСбоем(t *testing.T)
|
||||
t.Error("отмена не отмечена вовсе — исход операции обязан быть виден")
|
||||
}
|
||||
}
|
||||
|
||||
// metricsOf — список метрик каталога без версии витрины: версию проверяют
|
||||
// отдельные тесты, остальным нужен только состав ответа.
|
||||
func metricsOf(ctx context.Context, s *catalog.Service) ([]catalog.Metric, error) {
|
||||
snap, err := s.Metrics(ctx)
|
||||
return snap.Metrics, err
|
||||
}
|
||||
|
||||
// Каталог подписывает свой ответ версией витрины: без неё транспорту нечего
|
||||
// поставить в `ETag`, и условный запрос не работает вовсе.
|
||||
func TestКаталогОтдаётсяСВерсиейВитрины(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
st := openStore(t)
|
||||
snap, err := service(t, st).Metrics(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
if snap.Version == "" {
|
||||
t.Error("каталог собран на стоящей витрине и остался без версии")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Ответ каталога есть функция снимка И горизонта измерения: метка из будущего,
|
||||
// лежащая в витрине, въезжает в окно сама, с ходом часов и без единого коммита.
|
||||
// Построено враждебным проходом: та же версия витрины, `cumulative` против
|
||||
// `unknown`. Значит горизонт обязан входить в метку — иначе клиент с
|
||||
// `If-None-Match` получит `304` на изменившийся ответ.
|
||||
func TestГоризонтВходитВВерсиюОтвета(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
at := time.Date(2026, 6, 1, 10, 30, 0, 0, time.UTC)
|
||||
|
||||
if stamp("v", at) == stamp("v", at.Add(2*time.Hour)) {
|
||||
t.Error("версия не изменилась при сдвиге горизонта на два часа")
|
||||
}
|
||||
// Огрубление до часа точное, а не приблизительное: `hour_utc` объектов лежит
|
||||
// ровно на часах, поэтому отбор меняется ровно при переходе через час.
|
||||
// Внутри часа метка обязана стоять — иначе она дребезжала бы ежесекундно.
|
||||
if stamp("v", at) != stamp("v", at.Add(20*time.Minute)) {
|
||||
t.Error("версия сдвинулась внутри одного часа — метка дребезжит на месте")
|
||||
}
|
||||
if stamp("", at) != "" {
|
||||
t.Error("пустая версия витрины подписана горизонтом — подписывать нечем")
|
||||
}
|
||||
}
|
||||
@@ -176,9 +176,19 @@ func TestFoldНесравнимыеНаборыДаютWarn(t *testing.T) {
|
||||
t.Errorf("координаты объекта в записи не те: %q", at)
|
||||
}
|
||||
// И ни одного значения точки: данные о здоровье чувствительнее токенов.
|
||||
//
|
||||
// Метка времени из проверки исключается, и это не поблажка: она содержит
|
||||
// доли секунды, поэтому подстрока вроде "5.1" находится в ней примерно раз
|
||||
// на сотню прогонов — тест краснел от хода часов, а не от утечки. Проверять
|
||||
// надо запись без служебного поля, которое значений нести не может.
|
||||
delete(rec, "time")
|
||||
clean, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
t.Fatalf("запись лога не сериализуется: %v", err)
|
||||
}
|
||||
for _, secret := range []string{"5.1", "До еды"} {
|
||||
if strings.Contains(buf.String(), secret) {
|
||||
t.Errorf("в логе оказалось значение точки %q:\n%s", secret, buf.String())
|
||||
if strings.Contains(string(clean), secret) {
|
||||
t.Errorf("в логе оказалось значение точки %q:\n%s", secret, clean)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,36 @@ type catalogResponse struct {
|
||||
// сравнивающий разобранные структуры, этого не увидел бы — потому приёмочная
|
||||
// проверка сравнивает байты ответа. Второй страховки здесь нет намеренно:
|
||||
// подстраховка поверх подстраховки прячет отказ первой.
|
||||
//
|
||||
// Условный запрос стоит ПОСЛЕ проверки токена (её ставит роутер) и ДО сборки
|
||||
// снимка: в этом весь смысл — самый частый запрос потребителя есть повтор
|
||||
// неизменившегося, и он не должен стоить ни снимка, ни разжатия точек.
|
||||
// scopeMetrics — область действия метки каталога. Ответ маршрута не зависит от
|
||||
// параметров запроса, поэтому область постоянна; у точек и MCP на её месте
|
||||
// будет канонизированная форма запроса.
|
||||
const scopeMetrics = "metrics"
|
||||
|
||||
func (a *api) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
metrics, err := a.catalog.Metrics(r.Context())
|
||||
// Values, а не Get: `If-None-Match` клиент вправе прислать несколькими
|
||||
// строками, и `Get` увидел бы только первую — часть меток осталась бы
|
||||
// нерассмотренной.
|
||||
if cond := r.Header.Values("If-None-Match"); len(cond) > 0 {
|
||||
// Отказ пробы глушится намеренно: он означает лишь, что условного
|
||||
// ответа не будет, — а настоящий отказ базы всплывёт сборкой каталога
|
||||
// строкой ниже и будет назван ею один раз.
|
||||
// Версию спрашиваем У КАТАЛОГА, а не у хранилища: ответ есть функция не
|
||||
// только состояния витрины, и что ещё в него входит, знает домен. Read
|
||||
// API точек ответит здесь же своей версией, включающей параметры
|
||||
// запроса, — транспорту эти правила знать незачем.
|
||||
if version, err := a.catalog.Version(r.Context()); err == nil {
|
||||
if tag := etag(scopeMetrics, version); notModified(cond, tag) {
|
||||
writeNotModified(w, tag)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
snap, err := a.catalog.Metrics(r.Context())
|
||||
if err != nil {
|
||||
// Исход операции логирует доменный слой, транспорт только переводит его
|
||||
// в ответ. Наружу уходит человекочитаемое сообщение, а не текст ошибки:
|
||||
@@ -30,5 +58,9 @@ func (a *api) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, "каталог не собрался")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, catalogResponse{Metrics: metrics})
|
||||
// Версии может не быть — витрина изменилась, пока ответ собирался. Тогда
|
||||
// ответ уходит без метки: это ровно поведение до появления условного
|
||||
// запроса, то есть деградация в безопасную сторону.
|
||||
setReadHeaders(w, etag(scopeMetrics, snap.Version))
|
||||
writeJSON(w, http.StatusOK, catalogResponse{Metrics: snap.Metrics})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Условный запрос читающих маршрутов. Помощник общий намеренно: тот же ответ
|
||||
// понадобится точкам и MCP, а протокол здесь ровно такой, каким его описывает
|
||||
// HTTP, — второй его экземпляр разошёлся бы с первым в мелочи вроде слабого
|
||||
// сравнения.
|
||||
//
|
||||
// ГРАНИЦА, которую обязан знать следующий потребитель: метка действительна
|
||||
// только в пределах одного адреса ресурса. У каталога ответ зависит лишь от
|
||||
// состояния витрины, поэтому версии достаточно; у Read API точек ответ есть
|
||||
// функция параметров запроса, а у MCP адреса нет вовсе — там в метку обязана
|
||||
// входить канонизированная форма запроса, иначе «не изменилось» ответит на
|
||||
// другой набор данных.
|
||||
|
||||
// etag собирает метку HTTP из области действия и версии ответа.
|
||||
//
|
||||
// Область — параметр, а не забота вызывающего: она и есть то, что помощник
|
||||
// обязан не дать забыть. Метка действительна в пределах ОДНОГО ресурса, и
|
||||
// маршрут, чей ответ зависит от параметров запроса (Read API точек) или у
|
||||
// которого адреса нет вовсе (MCP), кладёт сюда их канонизированную форму.
|
||||
// Прозой это уже было написано — и прозу компилятор не проверяет: `etag(v)` у
|
||||
// второго маршрута собрался бы и отдал `304` на чужой набор данных.
|
||||
//
|
||||
// Метка СЛАБАЯ, и это не осторожность. Ответ есть функция не только снимка, но
|
||||
// и горизонта измерения (`Now()` плюс час), а горизонт едет вместе с часами:
|
||||
// пока в витрине нет меток из будущего, ход часов ответ не двигает, но метки из
|
||||
// будущего в ней возможны — сбитые часы телефона, чужое тело в приёме. Слабая
|
||||
// метка это допускает, сильная обещала бы побайтовое равенство, которого в этом
|
||||
// случае нет. На исход `304` форма не влияет: `If-None-Match` сравнивается
|
||||
// слабо в любом случае.
|
||||
func etag(scope, version string) string {
|
||||
if version == "" {
|
||||
return ""
|
||||
}
|
||||
return `W/"` + scope + "." + version + `"`
|
||||
}
|
||||
|
||||
// notModified отвечает, покрывает ли условие запроса текущую метку.
|
||||
//
|
||||
// Сравнение слабое: `W/"x"` и `"x"` — одна и та же метка, так предписывает
|
||||
// HTTP для `If-None-Match`. Звёздочка совпадает с любой существующей меткой.
|
||||
//
|
||||
// Неразбираемое значение условия — не отказ, а невыполненное условие: клиент,
|
||||
// приславший мусор, получает данные, а не `400`. Пустая метка (подписать ответ
|
||||
// нечем) не совпадает ни с чем, включая звёздочку: подтверждать неизменность
|
||||
// нечем.
|
||||
//
|
||||
// Алгоритм повторяет `net/http/fs.go` (`scanETag`, `etagWeakMatch`,
|
||||
// `checkIfNoneMatch`): там он есть, но неэкспортирован, и копия дешевле
|
||||
// зависимости. Копия ПОЛНАЯ, включая сканер: резать список по запятой нельзя —
|
||||
// запятая законный символ внутри метки, а в метку читающего маршрута once
|
||||
// попадёт канонизированная форма запроса, где запятая естественна. Тогда `304`
|
||||
// перестал бы срабатывать вообще, и симптом («условный запрос не экономит»)
|
||||
// увёл бы отладку в маршрут, а не в помощника.
|
||||
func notModified(header []string, tag string) bool {
|
||||
if tag == "" {
|
||||
return false
|
||||
}
|
||||
for _, line := range header {
|
||||
for {
|
||||
line = textproto.TrimString(line)
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
if line[0] == ',' {
|
||||
line = line[1:]
|
||||
continue
|
||||
}
|
||||
if line[0] == '*' {
|
||||
// Звёздочка совпадает с ЛЮБОЙ существующей меткой, а не с любым
|
||||
// состоянием: метки нет — условие не выполнено, и клиент со
|
||||
// звёздочкой получает данные, а не вечный `304`.
|
||||
return true
|
||||
}
|
||||
candidate, remain := scanETag(line)
|
||||
if candidate == "" {
|
||||
// Нечитаемая метка прекращает разбор строки: так делает stdlib,
|
||||
// и это не отказ — условие просто не выполнено.
|
||||
break
|
||||
}
|
||||
if strings.TrimPrefix(candidate, `W/`) == strings.TrimPrefix(tag, `W/`) {
|
||||
return true
|
||||
}
|
||||
line = remain
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// scanETag откусывает метку от начала строки и отдаёт остаток. Копия
|
||||
// `net/http/fs.go`; диапазоны символов — `etagc` из RFC 9110, в них ВХОДИТ
|
||||
// запятая.
|
||||
func scanETag(s string) (tag, remain string) {
|
||||
s = textproto.TrimString(s)
|
||||
start := 0
|
||||
if strings.HasPrefix(s, "W/") {
|
||||
start = 2
|
||||
}
|
||||
if len(s[start:]) < 2 || s[start] != '"' {
|
||||
return "", ""
|
||||
}
|
||||
for i := start + 1; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c == 0x21 || c >= 0x23 && c <= 0x7E || c >= 0x80:
|
||||
// шум внутри метки — законные символы
|
||||
case c == '"':
|
||||
return s[:i+1], s[i+1:]
|
||||
default:
|
||||
return "", ""
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// writeNotModified отвечает `304`: та же метка, никакого тела.
|
||||
//
|
||||
// Представленческие заголовки снимаются — так делает и stdlib
|
||||
// (`net/http/fs.go`, `writeNotModified`) со ссылкой на RFC 9110: у ответа без
|
||||
// тела нечего описывать, а `Content-Length`, доживший до `304`, вводит в
|
||||
// заблуждение любой кеш. Тело рантайм и так не пропустит
|
||||
// (`bodyAllowedForStatus`), но полагаться на это значило бы получать
|
||||
// проглоченную ошибку записи вместо ответа.
|
||||
func writeNotModified(w http.ResponseWriter, tag string) {
|
||||
h := w.Header()
|
||||
h.Del("Content-Type")
|
||||
h.Del("Content-Length")
|
||||
h.Del("Content-Encoding")
|
||||
setReadHeaders(w, tag)
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
}
|
||||
|
||||
// setReadHeaders вешает на ответ чтения метку и правило кеширования.
|
||||
//
|
||||
// `private, no-cache` означает «кешируй, но каждый раз спрашивай»: ровно то,
|
||||
// ради чего заведена метка. Заголовок обязателен, а не желателен, — без него
|
||||
// промежуточный кеш вправе решить по эвристике, что выгрузку истории здоровья
|
||||
// можно подержать у себя.
|
||||
func setReadHeaders(w http.ResponseWriter, tag string) {
|
||||
w.Header().Set("Cache-Control", "private, no-cache")
|
||||
if tag != "" {
|
||||
w.Header().Set("ETag", tag)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
func conditionalGet(t *testing.T, h http.Handler, auth, cond string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics", nil)
|
||||
if auth != "" {
|
||||
req.Header.Set("Authorization", auth)
|
||||
}
|
||||
if cond != "" {
|
||||
req.Header.Set("If-None-Match", cond)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func point(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)
|
||||
}
|
||||
}
|
||||
|
||||
// Обещание клиенту целиком: на неизменившейся витрине метка та же и байты те
|
||||
// же, а повтор с этой меткой не собирает снимка вовсе.
|
||||
func TestКаталогПовторНаНеизменившейсяВитрине(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
point(t, st, "step_count", time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC))
|
||||
|
||||
first := getCatalog(t, h, "")
|
||||
tag := first.Header().Get("ETag")
|
||||
if tag == "" {
|
||||
t.Fatal("ответ ушёл без метки")
|
||||
}
|
||||
if got := first.Header().Get("Cache-Control"); got != "private, no-cache" {
|
||||
t.Errorf("Cache-Control %q — выгрузку здоровья вправе сохранить любой посредник", got)
|
||||
}
|
||||
|
||||
second := getCatalog(t, h, "")
|
||||
if got := second.Header().Get("ETag"); got != tag {
|
||||
t.Errorf("метка сдвинулась на неизменившейся витрине: %q → %q", tag, got)
|
||||
}
|
||||
if first.Body.String() != second.Body.String() {
|
||||
t.Error("два ответа на неизменившейся витрине разошлись байтами")
|
||||
}
|
||||
|
||||
cond := conditionalGet(t, h, "", tag)
|
||||
if cond.Code != http.StatusNotModified {
|
||||
t.Fatalf("статус %d, ждали 304: %s", cond.Code, cond.Body.String())
|
||||
}
|
||||
if cond.Body.Len() != 0 {
|
||||
t.Errorf("у 304 есть тело: %q", cond.Body.String())
|
||||
}
|
||||
if got := cond.Header().Get("ETag"); got != tag {
|
||||
t.Errorf("метка на 304 — %q, ждали %q", got, tag)
|
||||
}
|
||||
if got := cond.Header().Get("Content-Type"); got != "" {
|
||||
t.Errorf("у 304 остались представленческие заголовки: Content-Type=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Свёртка записала объект — метка обязана смениться, иначе клиент останется на
|
||||
// устаревшем ответе навсегда.
|
||||
func TestКаталогМенялсяПослеЗаписи(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
at := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
|
||||
point(t, st, "step_count", at)
|
||||
|
||||
tag := getCatalog(t, h, "").Header().Get("ETag")
|
||||
point(t, st, "heart_rate", at)
|
||||
|
||||
rec := conditionalGet(t, h, "", tag)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("статус %d, ждали 200 — витрина изменилась", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("ETag"); got == tag {
|
||||
t.Error("метка не изменилась после записи в витрину")
|
||||
}
|
||||
}
|
||||
|
||||
// Звёздочка совпадает с любой существующей меткой; мусор условия не выполняет
|
||||
// и отказом не является.
|
||||
func TestКаталогФормыУсловия(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
point(t, st, "step_count", time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cond string
|
||||
want int
|
||||
}{
|
||||
{"звёздочка", "*", http.StatusNotModified},
|
||||
{"мусор", "не-метка", http.StatusOK},
|
||||
{"чужая метка", `W/"gen-нет"`, http.StatusOK},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := conditionalGet(t, h, "", c.cond).Code; got != c.want {
|
||||
t.Errorf("%s: статус %d, ждали %d", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Токен проверяется РАНЬШЕ условия: `304` без токена подтверждал бы состояние
|
||||
// витрины тому, кому она не открыта.
|
||||
func TestУсловныйЗапросБезТокена(t *testing.T) {
|
||||
read := []string{"read-token"}
|
||||
h, st, _ := newAPITokens(t, nil, read)
|
||||
point(t, st, "step_count", time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC))
|
||||
|
||||
tag := conditionalGet(t, h, "Bearer read-token", "").Header().Get("ETag")
|
||||
if tag == "" {
|
||||
t.Fatal("ответ ушёл без метки")
|
||||
}
|
||||
|
||||
for _, cond := range []string{tag, "*"} {
|
||||
if got := conditionalGet(t, h, "", cond).Code; got != http.StatusUnauthorized {
|
||||
t.Errorf("условие %q без токена дало статус %d, ждали 401", cond, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Смысл условного запроса — в том, что снимок не открывается вовсе. Оракул
|
||||
// внешний: каталог при сборке пишет владельцу предупреждение о данных из
|
||||
// будущего, и его отсутствие означает, что сборки не было.
|
||||
func TestУсловныйОтветНеСобираетКаталог(t *testing.T) {
|
||||
h, st, _, seen := newAPILogged(t, nil, nil)
|
||||
// Метка из будущего: при каждой сборке каталога она даёт `WARN`.
|
||||
point(t, st, "step_count", time.Now().UTC().Add(48*time.Hour))
|
||||
|
||||
tag := getCatalog(t, h, "").Header().Get("ETag")
|
||||
if !seen.has("future data") {
|
||||
t.Fatal("сборка каталога не дала ожидаемого предупреждения — оракул непригоден")
|
||||
}
|
||||
|
||||
seen.reset()
|
||||
if got := conditionalGet(t, h, "", tag).Code; got != http.StatusNotModified {
|
||||
t.Fatalf("статус %d, ждали 304", got)
|
||||
}
|
||||
if seen.has("future data") {
|
||||
t.Error("условный ответ собрал каталог: снимок открыт зря")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Сравнение меток слабое, звёздочка совпадает с любой существующей меткой, а
|
||||
// мусор условия не выполняет — и это не отказ: клиент, приславший кривой
|
||||
// заголовок, получает данные, а не `400`.
|
||||
func TestУсловиеЗапроса(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const tag = `W/"metrics.gen-7"`
|
||||
cases := []struct {
|
||||
name string
|
||||
header []string
|
||||
tag string
|
||||
want bool
|
||||
}{
|
||||
{"заголовка нет", nil, tag, false},
|
||||
{"пустая строка", []string{""}, tag, false},
|
||||
{"та же метка", []string{tag}, tag, true},
|
||||
{"та же метка без W/", []string{`"metrics.gen-7"`}, tag, true},
|
||||
{"чужая метка", []string{`W/"metrics.gen-8"`}, tag, false},
|
||||
{"метка другого ресурса", []string{`W/"points.gen-7"`}, tag, false},
|
||||
{"список, метка вторая", []string{`W/"metrics.gen-1", W/"metrics.gen-7"`}, tag, true},
|
||||
{"две строки заголовка", []string{`W/"metrics.gen-1"`, `W/"metrics.gen-7"`}, tag, true},
|
||||
{"запятая внутри метки", []string{`W/"points.a,b-7"`}, `W/"points.a,b-7"`, true},
|
||||
{"метка без закрывающей кавычки", []string{`W/"metrics.gen-7`}, tag, false},
|
||||
{"звёздочка", []string{"*"}, tag, true},
|
||||
{"звёздочка без метки", []string{"*"}, "", false},
|
||||
{"мусор", []string{"metrics.gen-7"}, tag, false},
|
||||
{"мусор со звёздочкой внутри", []string{`"*"`}, tag, false},
|
||||
{"метки нет", []string{tag}, "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := notModified(c.header, c.tag); got != c.want {
|
||||
t.Errorf("%s: %v, ждали %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Пустая версия метки не даёт: подписать ответ нечем, и притворяться нельзя.
|
||||
func TestМеткаИзВерсии(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := etag("metrics", ""); got != "" {
|
||||
t.Errorf("пустая версия дала метку %q", got)
|
||||
}
|
||||
if got := etag("metrics", "gen-7"); got != `W/"metrics.gen-7"` {
|
||||
t.Errorf("метка %q, ждали слабую с областью", got)
|
||||
}
|
||||
if etag("metrics", "gen-7") == etag("points", "gen-7") {
|
||||
t.Error("метки разных ресурсов совпали — 304 отдал бы чужие данные")
|
||||
}
|
||||
}
|
||||
|
||||
// Подписать нечем — заголовка нет вовсе. Пустой `ETag:` синтаксически невалиден,
|
||||
// и что с ним сделает посредник, не определено ничем.
|
||||
func TestОтветБезВерсииНеНесётМетки(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
setReadHeaders(rec, "")
|
||||
if _, ok := rec.Header()["Etag"]; ok {
|
||||
t.Errorf("ответ без версии несёт метку %q", rec.Header().Get("ETag"))
|
||||
}
|
||||
if got := rec.Header().Get("Cache-Control"); got != "private, no-cache" {
|
||||
t.Errorf("правило кеширования %q — оно не зависит от наличия метки", got)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -294,6 +295,16 @@ func newAPI(t *testing.T, writeTokens []string) (http.Handler, *store.Store) {
|
||||
// переписывать два десятка вызовов ради одного параметра.
|
||||
func newAPITokens(t *testing.T, writeTokens, readTokens []string) (http.Handler, *store.Store, *catalog.Service) {
|
||||
t.Helper()
|
||||
|
||||
h, st, cat, _ := newAPILogged(t, writeTokens, readTokens)
|
||||
return h, st, cat
|
||||
}
|
||||
|
||||
// newAPILogged отдаёт ещё и записи лога. Нужен там, где лог служит ОРАКУЛОМ, а
|
||||
// не наблюдением: единственный внешний признак того, что каталог собирался, —
|
||||
// его предупреждения владельцу.
|
||||
func newAPILogged(t *testing.T, writeTokens, readTokens []string) (http.Handler, *store.Store, *catalog.Service, *records) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
|
||||
@@ -307,7 +318,8 @@ func newAPITokens(t *testing.T, writeTokens, readTokens []string) (http.Handler,
|
||||
t.Fatalf("archive.New: %v", err)
|
||||
}
|
||||
|
||||
log := slog.New(slog.DiscardHandler)
|
||||
seen := &records{}
|
||||
log := slog.New(seen)
|
||||
cat := catalog.New(st, log)
|
||||
h := httpapi.New(httpapi.Options{
|
||||
Ingest: ingest.New(arch, st, nil, log),
|
||||
@@ -320,5 +332,41 @@ func newAPITokens(t *testing.T, writeTokens, readTokens []string) (http.Handler,
|
||||
// и это ровно тот транспорт, на котором приём обязан продолжать работать.
|
||||
IngestWriteBudget: time.Minute,
|
||||
})
|
||||
return h, st, cat
|
||||
return h, st, cat, seen
|
||||
}
|
||||
|
||||
// records — slog.Handler, копящий сообщения. Значений атрибутов не хранит:
|
||||
// проверяется факт записи, а данные о здоровье в тесты тащить незачем.
|
||||
type records struct {
|
||||
mu sync.Mutex
|
||||
msg []string
|
||||
}
|
||||
|
||||
func (r *records) Enabled(context.Context, slog.Level) bool { return true }
|
||||
|
||||
func (r *records) Handle(_ context.Context, rec slog.Record) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.msg = append(r.msg, rec.Message)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *records) WithAttrs([]slog.Attr) slog.Handler { return r }
|
||||
func (r *records) WithGroup(string) slog.Handler { return r }
|
||||
|
||||
func (r *records) has(msg string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, m := range r.msg {
|
||||
if m == msg {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *records) reset() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.msg = nil
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ func measureStyles(t *testing.T, st *store.Store) {
|
||||
t.Helper()
|
||||
|
||||
started := time.Now()
|
||||
metrics, err := catalog.New(st, slog.New(slog.DiscardHandler)).Metrics(context.Background())
|
||||
metrics, err := metricsOf(context.Background(), catalog.New(st, slog.New(slog.DiscardHandler)))
|
||||
if err != nil {
|
||||
t.Fatalf("каталог: %v", err)
|
||||
}
|
||||
@@ -291,3 +291,10 @@ func gunzip(t *testing.T, body []byte) []byte {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// metricsOf — список метрик каталога без версии витрины: версию проверяют
|
||||
// отдельные тесты, остальным нужен только состав ответа.
|
||||
func metricsOf(ctx context.Context, s *catalog.Service) ([]catalog.Metric, error) {
|
||||
snap, err := s.Metrics(ctx)
|
||||
return snap.Metrics, err
|
||||
}
|
||||
|
||||
@@ -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