Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос
- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как «разобрано целиком» - каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка собрана из всего, от чего зависит ответ: версии витрины (`data_version` с закреплённого соединения плюс поколение — значение локально для соединения и не переживает переоткрытия), горизонта измерения и области действия ресурса. Версия снимается до и после сборки: снятая после пометила бы устаревший снимок свежим номером - предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший значение точки в сыром буфере записи лога
This commit is contained in:
@@ -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("условный ответ собрал каталог: снимок открыт зря")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user