Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос
- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как «разобрано целиком» - каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка собрана из всего, от чего зависит ответ: версии витрины (`data_version` с закреплённого соединения плюс поколение — значение локально для соединения и не переживает переоткрытия), горизонта измерения и области действия ресурса. Версия снимается до и после сборки: снятая после пометила бы устаревший снимок свежим номером - предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший значение точки в сыром буфере записи лога
This commit is contained in:
@@ -21,8 +21,36 @@ type catalogResponse struct {
|
||||
// сравнивающий разобранные структуры, этого не увидел бы — потому приёмочная
|
||||
// проверка сравнивает байты ответа. Второй страховки здесь нет намеренно:
|
||||
// подстраховка поверх подстраховки прячет отказ первой.
|
||||
//
|
||||
// Условный запрос стоит ПОСЛЕ проверки токена (её ставит роутер) и ДО сборки
|
||||
// снимка: в этом весь смысл — самый частый запрос потребителя есть повтор
|
||||
// неизменившегося, и он не должен стоить ни снимка, ни разжатия точек.
|
||||
// scopeMetrics — область действия метки каталога. Ответ маршрута не зависит от
|
||||
// параметров запроса, поэтому область постоянна; у точек и MCP на её месте
|
||||
// будет канонизированная форма запроса.
|
||||
const scopeMetrics = "metrics"
|
||||
|
||||
func (a *api) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
metrics, err := a.catalog.Metrics(r.Context())
|
||||
// Values, а не Get: `If-None-Match` клиент вправе прислать несколькими
|
||||
// строками, и `Get` увидел бы только первую — часть меток осталась бы
|
||||
// нерассмотренной.
|
||||
if cond := r.Header.Values("If-None-Match"); len(cond) > 0 {
|
||||
// Отказ пробы глушится намеренно: он означает лишь, что условного
|
||||
// ответа не будет, — а настоящий отказ базы всплывёт сборкой каталога
|
||||
// строкой ниже и будет назван ею один раз.
|
||||
// Версию спрашиваем У КАТАЛОГА, а не у хранилища: ответ есть функция не
|
||||
// только состояния витрины, и что ещё в него входит, знает домен. Read
|
||||
// API точек ответит здесь же своей версией, включающей параметры
|
||||
// запроса, — транспорту эти правила знать незачем.
|
||||
if version, err := a.catalog.Version(r.Context()); err == nil {
|
||||
if tag := etag(scopeMetrics, version); notModified(cond, tag) {
|
||||
writeNotModified(w, tag)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
snap, err := a.catalog.Metrics(r.Context())
|
||||
if err != nil {
|
||||
// Исход операции логирует доменный слой, транспорт только переводит его
|
||||
// в ответ. Наружу уходит человекочитаемое сообщение, а не текст ошибки:
|
||||
@@ -30,5 +58,9 @@ func (a *api) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, "каталог не собрался")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, catalogResponse{Metrics: metrics})
|
||||
// Версии может не быть — витрина изменилась, пока ответ собирался. Тогда
|
||||
// ответ уходит без метки: это ровно поведение до появления условного
|
||||
// запроса, то есть деградация в безопасную сторону.
|
||||
setReadHeaders(w, etag(scopeMetrics, snap.Version))
|
||||
writeJSON(w, http.StatusOK, catalogResponse{Metrics: snap.Metrics})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Условный запрос читающих маршрутов. Помощник общий намеренно: тот же ответ
|
||||
// понадобится точкам и MCP, а протокол здесь ровно такой, каким его описывает
|
||||
// HTTP, — второй его экземпляр разошёлся бы с первым в мелочи вроде слабого
|
||||
// сравнения.
|
||||
//
|
||||
// ГРАНИЦА, которую обязан знать следующий потребитель: метка действительна
|
||||
// только в пределах одного адреса ресурса. У каталога ответ зависит лишь от
|
||||
// состояния витрины, поэтому версии достаточно; у Read API точек ответ есть
|
||||
// функция параметров запроса, а у MCP адреса нет вовсе — там в метку обязана
|
||||
// входить канонизированная форма запроса, иначе «не изменилось» ответит на
|
||||
// другой набор данных.
|
||||
|
||||
// etag собирает метку HTTP из области действия и версии ответа.
|
||||
//
|
||||
// Область — параметр, а не забота вызывающего: она и есть то, что помощник
|
||||
// обязан не дать забыть. Метка действительна в пределах ОДНОГО ресурса, и
|
||||
// маршрут, чей ответ зависит от параметров запроса (Read API точек) или у
|
||||
// которого адреса нет вовсе (MCP), кладёт сюда их канонизированную форму.
|
||||
// Прозой это уже было написано — и прозу компилятор не проверяет: `etag(v)` у
|
||||
// второго маршрута собрался бы и отдал `304` на чужой набор данных.
|
||||
//
|
||||
// Метка СЛАБАЯ, и это не осторожность. Ответ есть функция не только снимка, но
|
||||
// и горизонта измерения (`Now()` плюс час), а горизонт едет вместе с часами:
|
||||
// пока в витрине нет меток из будущего, ход часов ответ не двигает, но метки из
|
||||
// будущего в ней возможны — сбитые часы телефона, чужое тело в приёме. Слабая
|
||||
// метка это допускает, сильная обещала бы побайтовое равенство, которого в этом
|
||||
// случае нет. На исход `304` форма не влияет: `If-None-Match` сравнивается
|
||||
// слабо в любом случае.
|
||||
func etag(scope, version string) string {
|
||||
if version == "" {
|
||||
return ""
|
||||
}
|
||||
return `W/"` + scope + "." + version + `"`
|
||||
}
|
||||
|
||||
// notModified отвечает, покрывает ли условие запроса текущую метку.
|
||||
//
|
||||
// Сравнение слабое: `W/"x"` и `"x"` — одна и та же метка, так предписывает
|
||||
// HTTP для `If-None-Match`. Звёздочка совпадает с любой существующей меткой.
|
||||
//
|
||||
// Неразбираемое значение условия — не отказ, а невыполненное условие: клиент,
|
||||
// приславший мусор, получает данные, а не `400`. Пустая метка (подписать ответ
|
||||
// нечем) не совпадает ни с чем, включая звёздочку: подтверждать неизменность
|
||||
// нечем.
|
||||
//
|
||||
// Алгоритм повторяет `net/http/fs.go` (`scanETag`, `etagWeakMatch`,
|
||||
// `checkIfNoneMatch`): там он есть, но неэкспортирован, и копия дешевле
|
||||
// зависимости. Копия ПОЛНАЯ, включая сканер: резать список по запятой нельзя —
|
||||
// запятая законный символ внутри метки, а в метку читающего маршрута once
|
||||
// попадёт канонизированная форма запроса, где запятая естественна. Тогда `304`
|
||||
// перестал бы срабатывать вообще, и симптом («условный запрос не экономит»)
|
||||
// увёл бы отладку в маршрут, а не в помощника.
|
||||
func notModified(header []string, tag string) bool {
|
||||
if tag == "" {
|
||||
return false
|
||||
}
|
||||
for _, line := range header {
|
||||
for {
|
||||
line = textproto.TrimString(line)
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
if line[0] == ',' {
|
||||
line = line[1:]
|
||||
continue
|
||||
}
|
||||
if line[0] == '*' {
|
||||
// Звёздочка совпадает с ЛЮБОЙ существующей меткой, а не с любым
|
||||
// состоянием: метки нет — условие не выполнено, и клиент со
|
||||
// звёздочкой получает данные, а не вечный `304`.
|
||||
return true
|
||||
}
|
||||
candidate, remain := scanETag(line)
|
||||
if candidate == "" {
|
||||
// Нечитаемая метка прекращает разбор строки: так делает stdlib,
|
||||
// и это не отказ — условие просто не выполнено.
|
||||
break
|
||||
}
|
||||
if strings.TrimPrefix(candidate, `W/`) == strings.TrimPrefix(tag, `W/`) {
|
||||
return true
|
||||
}
|
||||
line = remain
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// scanETag откусывает метку от начала строки и отдаёт остаток. Копия
|
||||
// `net/http/fs.go`; диапазоны символов — `etagc` из RFC 9110, в них ВХОДИТ
|
||||
// запятая.
|
||||
func scanETag(s string) (tag, remain string) {
|
||||
s = textproto.TrimString(s)
|
||||
start := 0
|
||||
if strings.HasPrefix(s, "W/") {
|
||||
start = 2
|
||||
}
|
||||
if len(s[start:]) < 2 || s[start] != '"' {
|
||||
return "", ""
|
||||
}
|
||||
for i := start + 1; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c == 0x21 || c >= 0x23 && c <= 0x7E || c >= 0x80:
|
||||
// шум внутри метки — законные символы
|
||||
case c == '"':
|
||||
return s[:i+1], s[i+1:]
|
||||
default:
|
||||
return "", ""
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// writeNotModified отвечает `304`: та же метка, никакого тела.
|
||||
//
|
||||
// Представленческие заголовки снимаются — так делает и stdlib
|
||||
// (`net/http/fs.go`, `writeNotModified`) со ссылкой на RFC 9110: у ответа без
|
||||
// тела нечего описывать, а `Content-Length`, доживший до `304`, вводит в
|
||||
// заблуждение любой кеш. Тело рантайм и так не пропустит
|
||||
// (`bodyAllowedForStatus`), но полагаться на это значило бы получать
|
||||
// проглоченную ошибку записи вместо ответа.
|
||||
func writeNotModified(w http.ResponseWriter, tag string) {
|
||||
h := w.Header()
|
||||
h.Del("Content-Type")
|
||||
h.Del("Content-Length")
|
||||
h.Del("Content-Encoding")
|
||||
setReadHeaders(w, tag)
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
}
|
||||
|
||||
// setReadHeaders вешает на ответ чтения метку и правило кеширования.
|
||||
//
|
||||
// `private, no-cache` означает «кешируй, но каждый раз спрашивай»: ровно то,
|
||||
// ради чего заведена метка. Заголовок обязателен, а не желателен, — без него
|
||||
// промежуточный кеш вправе решить по эвристике, что выгрузку истории здоровья
|
||||
// можно подержать у себя.
|
||||
func setReadHeaders(w http.ResponseWriter, tag string) {
|
||||
w.Header().Set("Cache-Control", "private, no-cache")
|
||||
if tag != "" {
|
||||
w.Header().Set("ETag", tag)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
func conditionalGet(t *testing.T, h http.Handler, auth, cond string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics", nil)
|
||||
if auth != "" {
|
||||
req.Header.Set("Authorization", auth)
|
||||
}
|
||||
if cond != "" {
|
||||
req.Header.Set("If-None-Match", cond)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func point(t *testing.T, st *store.Store, metric string, at time.Time) {
|
||||
t.Helper()
|
||||
|
||||
_, err := st.Merge(context.Background(), store.Incoming{Points: []store.IncomingPoint{{
|
||||
Metric: metric, Layer: "minute", Units: "count",
|
||||
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":1}`)},
|
||||
}}}, store.DeliveryRef{ID: "d"})
|
||||
if err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Обещание клиенту целиком: на неизменившейся витрине метка та же и байты те
|
||||
// же, а повтор с этой меткой не собирает снимка вовсе.
|
||||
func TestКаталогПовторНаНеизменившейсяВитрине(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
point(t, st, "step_count", time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC))
|
||||
|
||||
first := getCatalog(t, h, "")
|
||||
tag := first.Header().Get("ETag")
|
||||
if tag == "" {
|
||||
t.Fatal("ответ ушёл без метки")
|
||||
}
|
||||
if got := first.Header().Get("Cache-Control"); got != "private, no-cache" {
|
||||
t.Errorf("Cache-Control %q — выгрузку здоровья вправе сохранить любой посредник", got)
|
||||
}
|
||||
|
||||
second := getCatalog(t, h, "")
|
||||
if got := second.Header().Get("ETag"); got != tag {
|
||||
t.Errorf("метка сдвинулась на неизменившейся витрине: %q → %q", tag, got)
|
||||
}
|
||||
if first.Body.String() != second.Body.String() {
|
||||
t.Error("два ответа на неизменившейся витрине разошлись байтами")
|
||||
}
|
||||
|
||||
cond := conditionalGet(t, h, "", tag)
|
||||
if cond.Code != http.StatusNotModified {
|
||||
t.Fatalf("статус %d, ждали 304: %s", cond.Code, cond.Body.String())
|
||||
}
|
||||
if cond.Body.Len() != 0 {
|
||||
t.Errorf("у 304 есть тело: %q", cond.Body.String())
|
||||
}
|
||||
if got := cond.Header().Get("ETag"); got != tag {
|
||||
t.Errorf("метка на 304 — %q, ждали %q", got, tag)
|
||||
}
|
||||
if got := cond.Header().Get("Content-Type"); got != "" {
|
||||
t.Errorf("у 304 остались представленческие заголовки: Content-Type=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Свёртка записала объект — метка обязана смениться, иначе клиент останется на
|
||||
// устаревшем ответе навсегда.
|
||||
func TestКаталогМенялсяПослеЗаписи(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
at := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
|
||||
point(t, st, "step_count", at)
|
||||
|
||||
tag := getCatalog(t, h, "").Header().Get("ETag")
|
||||
point(t, st, "heart_rate", at)
|
||||
|
||||
rec := conditionalGet(t, h, "", tag)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("статус %d, ждали 200 — витрина изменилась", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("ETag"); got == tag {
|
||||
t.Error("метка не изменилась после записи в витрину")
|
||||
}
|
||||
}
|
||||
|
||||
// Звёздочка совпадает с любой существующей меткой; мусор условия не выполняет
|
||||
// и отказом не является.
|
||||
func TestКаталогФормыУсловия(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
point(t, st, "step_count", time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cond string
|
||||
want int
|
||||
}{
|
||||
{"звёздочка", "*", http.StatusNotModified},
|
||||
{"мусор", "не-метка", http.StatusOK},
|
||||
{"чужая метка", `W/"gen-нет"`, http.StatusOK},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := conditionalGet(t, h, "", c.cond).Code; got != c.want {
|
||||
t.Errorf("%s: статус %d, ждали %d", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Токен проверяется РАНЬШЕ условия: `304` без токена подтверждал бы состояние
|
||||
// витрины тому, кому она не открыта.
|
||||
func TestУсловныйЗапросБезТокена(t *testing.T) {
|
||||
read := []string{"read-token"}
|
||||
h, st, _ := newAPITokens(t, nil, read)
|
||||
point(t, st, "step_count", time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC))
|
||||
|
||||
tag := conditionalGet(t, h, "Bearer read-token", "").Header().Get("ETag")
|
||||
if tag == "" {
|
||||
t.Fatal("ответ ушёл без метки")
|
||||
}
|
||||
|
||||
for _, cond := range []string{tag, "*"} {
|
||||
if got := conditionalGet(t, h, "", cond).Code; got != http.StatusUnauthorized {
|
||||
t.Errorf("условие %q без токена дало статус %d, ждали 401", cond, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Смысл условного запроса — в том, что снимок не открывается вовсе. Оракул
|
||||
// внешний: каталог при сборке пишет владельцу предупреждение о данных из
|
||||
// будущего, и его отсутствие означает, что сборки не было.
|
||||
func TestУсловныйОтветНеСобираетКаталог(t *testing.T) {
|
||||
h, st, _, seen := newAPILogged(t, nil, nil)
|
||||
// Метка из будущего: при каждой сборке каталога она даёт `WARN`.
|
||||
point(t, st, "step_count", time.Now().UTC().Add(48*time.Hour))
|
||||
|
||||
tag := getCatalog(t, h, "").Header().Get("ETag")
|
||||
if !seen.has("future data") {
|
||||
t.Fatal("сборка каталога не дала ожидаемого предупреждения — оракул непригоден")
|
||||
}
|
||||
|
||||
seen.reset()
|
||||
if got := conditionalGet(t, h, "", tag).Code; got != http.StatusNotModified {
|
||||
t.Fatalf("статус %d, ждали 304", got)
|
||||
}
|
||||
if seen.has("future data") {
|
||||
t.Error("условный ответ собрал каталог: снимок открыт зря")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Сравнение меток слабое, звёздочка совпадает с любой существующей меткой, а
|
||||
// мусор условия не выполняет — и это не отказ: клиент, приславший кривой
|
||||
// заголовок, получает данные, а не `400`.
|
||||
func TestУсловиеЗапроса(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const tag = `W/"metrics.gen-7"`
|
||||
cases := []struct {
|
||||
name string
|
||||
header []string
|
||||
tag string
|
||||
want bool
|
||||
}{
|
||||
{"заголовка нет", nil, tag, false},
|
||||
{"пустая строка", []string{""}, tag, false},
|
||||
{"та же метка", []string{tag}, tag, true},
|
||||
{"та же метка без W/", []string{`"metrics.gen-7"`}, tag, true},
|
||||
{"чужая метка", []string{`W/"metrics.gen-8"`}, tag, false},
|
||||
{"метка другого ресурса", []string{`W/"points.gen-7"`}, tag, false},
|
||||
{"список, метка вторая", []string{`W/"metrics.gen-1", W/"metrics.gen-7"`}, tag, true},
|
||||
{"две строки заголовка", []string{`W/"metrics.gen-1"`, `W/"metrics.gen-7"`}, tag, true},
|
||||
{"запятая внутри метки", []string{`W/"points.a,b-7"`}, `W/"points.a,b-7"`, true},
|
||||
{"метка без закрывающей кавычки", []string{`W/"metrics.gen-7`}, tag, false},
|
||||
{"звёздочка", []string{"*"}, tag, true},
|
||||
{"звёздочка без метки", []string{"*"}, "", false},
|
||||
{"мусор", []string{"metrics.gen-7"}, tag, false},
|
||||
{"мусор со звёздочкой внутри", []string{`"*"`}, tag, false},
|
||||
{"метки нет", []string{tag}, "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := notModified(c.header, c.tag); got != c.want {
|
||||
t.Errorf("%s: %v, ждали %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Пустая версия метки не даёт: подписать ответ нечем, и притворяться нельзя.
|
||||
func TestМеткаИзВерсии(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := etag("metrics", ""); got != "" {
|
||||
t.Errorf("пустая версия дала метку %q", got)
|
||||
}
|
||||
if got := etag("metrics", "gen-7"); got != `W/"metrics.gen-7"` {
|
||||
t.Errorf("метка %q, ждали слабую с областью", got)
|
||||
}
|
||||
if etag("metrics", "gen-7") == etag("points", "gen-7") {
|
||||
t.Error("метки разных ресурсов совпали — 304 отдал бы чужие данные")
|
||||
}
|
||||
}
|
||||
|
||||
// Подписать нечем — заголовка нет вовсе. Пустой `ETag:` синтаксически невалиден,
|
||||
// и что с ним сделает посредник, не определено ничем.
|
||||
func TestОтветБезВерсииНеНесётМетки(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
setReadHeaders(rec, "")
|
||||
if _, ok := rec.Header()["Etag"]; ok {
|
||||
t.Errorf("ответ без версии несёт метку %q", rec.Header().Get("ETag"))
|
||||
}
|
||||
if got := rec.Header().Get("Cache-Control"); got != "private, no-cache" {
|
||||
t.Errorf("правило кеширования %q — оно не зависит от наличия метки", got)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -294,6 +295,16 @@ func newAPI(t *testing.T, writeTokens []string) (http.Handler, *store.Store) {
|
||||
// переписывать два десятка вызовов ради одного параметра.
|
||||
func newAPITokens(t *testing.T, writeTokens, readTokens []string) (http.Handler, *store.Store, *catalog.Service) {
|
||||
t.Helper()
|
||||
|
||||
h, st, cat, _ := newAPILogged(t, writeTokens, readTokens)
|
||||
return h, st, cat
|
||||
}
|
||||
|
||||
// newAPILogged отдаёт ещё и записи лога. Нужен там, где лог служит ОРАКУЛОМ, а
|
||||
// не наблюдением: единственный внешний признак того, что каталог собирался, —
|
||||
// его предупреждения владельцу.
|
||||
func newAPILogged(t *testing.T, writeTokens, readTokens []string) (http.Handler, *store.Store, *catalog.Service, *records) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
st, err := store.Open(filepath.Join(dir, "healthlog.db"))
|
||||
@@ -307,7 +318,8 @@ func newAPITokens(t *testing.T, writeTokens, readTokens []string) (http.Handler,
|
||||
t.Fatalf("archive.New: %v", err)
|
||||
}
|
||||
|
||||
log := slog.New(slog.DiscardHandler)
|
||||
seen := &records{}
|
||||
log := slog.New(seen)
|
||||
cat := catalog.New(st, log)
|
||||
h := httpapi.New(httpapi.Options{
|
||||
Ingest: ingest.New(arch, st, nil, log),
|
||||
@@ -320,5 +332,41 @@ func newAPITokens(t *testing.T, writeTokens, readTokens []string) (http.Handler,
|
||||
// и это ровно тот транспорт, на котором приём обязан продолжать работать.
|
||||
IngestWriteBudget: time.Minute,
|
||||
})
|
||||
return h, st, cat
|
||||
return h, st, cat, seen
|
||||
}
|
||||
|
||||
// records — slog.Handler, копящий сообщения. Значений атрибутов не хранит:
|
||||
// проверяется факт записи, а данные о здоровье в тесты тащить незачем.
|
||||
type records struct {
|
||||
mu sync.Mutex
|
||||
msg []string
|
||||
}
|
||||
|
||||
func (r *records) Enabled(context.Context, slog.Level) bool { return true }
|
||||
|
||||
func (r *records) Handle(_ context.Context, rec slog.Record) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.msg = append(r.msg, rec.Message)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *records) WithAttrs([]slog.Attr) slog.Handler { return r }
|
||||
func (r *records) WithGroup(string) slog.Handler { return r }
|
||||
|
||||
func (r *records) has(msg string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, m := range r.msg {
|
||||
if m == msg {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *records) reset() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.msg = nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user