Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос

- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал
  пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не
  только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под
  удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а
  при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как
  «разобрано целиком»
- каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка
  собрана из всего, от чего зависит ответ: версии витрины (`data_version` с
  закреплённого соединения плюс поколение — значение локально для соединения и
  не переживает переоткрытия), горизонта измерения и области действия ресурса.
  Версия снимается до и после сборки: снятая после пометила бы устаревший снимок
  свежим номером
- предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной
  ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший
  значение точки в сыром буфере записи лога
This commit is contained in:
av
2026-08-02 20:42:22 +03:00
parent 6b729bbd2f
commit 8db2ec7ff4
37 changed files with 3575 additions and 156 deletions
+75 -11
View File
@@ -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 {
+36 -14
View File
@@ -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("каталог собран на стоящей витрине и остался без версии")
}
}
+30
View File
@@ -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("пустая версия витрины подписана горизонтом — подписывать нечем")
}
}
+12 -2
View File
@@ -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)
}
}
}
+34 -2
View File
@@ -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})
}
+150
View File
@@ -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)
}
}
+159
View File
@@ -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("условный ответ собрал каталог: снимок открыт зря")
}
}
+72
View File
@@ -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)
}
}
+50 -2
View File
@@ -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
}
+8 -1
View File
@@ -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
}
+9
View File
@@ -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("хранилище закрыто")
+27
View File
@@ -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()
}
+182
View File
@@ -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()
}
+177
View File
@@ -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)
}
}
+222
View File
@@ -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)
}
}
+112
View File
@@ -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
}
+198
View File
@@ -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("чекпойнт на отменённом контексте прошёл успешно")
}
}