Цена читающего маршрута: чекпойнт 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
+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
}