Цена читающего маршрута: чекпойнт 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
+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("условный ответ собрал каталог: снимок открыт зря")
}
}