Приём отвечает 200 до свёртки, свёртку ведёт фоновый воркер
- Очередью служит сама таблица: доставка ждёт свёртки в статусе `pending`, канал несёт только бит «есть работа». Переполнять нечего, падение процесса очередь не теряет, а подбор `pending` при старте — обычный проход воркера, а не отдельный код. Классификация исхода общая с пересборкой журнала. - Исход разбора начал отражать доставку, а не обстоятельства: отмена и занятость базы статус не меняют (иначе конкуренция за базу выводила бы доставку из очереди навсегда), паника свёртки больше не валит процесс, а учёт доставки идёт через транзакцию с повторами. - Длинный бюджет ответа выдан маршруту приёма, а не всему серверу: `write_timeout` в Go покрывает и чтение тела, и общий подъём снял бы защиту с остальных маршрутов.
This commit is contained in:
@@ -23,22 +23,28 @@ type Options struct {
|
||||
Log *slog.Logger
|
||||
WriteTokens []string
|
||||
MaxBodyMB int
|
||||
// IngestWriteBudget — сколько отводится маршруту приёма на чтение тела
|
||||
// вместе с отправкой ответа. Ноль означает «полагаться на WriteTimeout
|
||||
// сервера», и полагаться на него нельзя, см. handleIngest.
|
||||
IngestWriteBudget time.Duration
|
||||
}
|
||||
|
||||
type api struct {
|
||||
ingest *ingest.Service
|
||||
log *slog.Logger
|
||||
writeTokens []string
|
||||
maxBody int64
|
||||
ingest *ingest.Service
|
||||
log *slog.Logger
|
||||
writeTokens []string
|
||||
maxBody int64
|
||||
ingestBudget time.Duration
|
||||
}
|
||||
|
||||
// New собирает HTTP-роутер.
|
||||
func New(o Options) http.Handler {
|
||||
a := &api{
|
||||
ingest: o.Ingest,
|
||||
log: o.Log,
|
||||
writeTokens: o.WriteTokens,
|
||||
maxBody: int64(o.MaxBodyMB) << 20,
|
||||
ingest: o.Ingest,
|
||||
log: o.Log,
|
||||
writeTokens: o.WriteTokens,
|
||||
maxBody: int64(o.MaxBodyMB) << 20,
|
||||
ingestBudget: o.IngestWriteBudget,
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -3,6 +3,7 @@ package httpapi_test
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -10,9 +11,9 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/archive"
|
||||
"git.vakhrushev.me/av/healthlog/internal/fold"
|
||||
"git.vakhrushev.me/av/healthlog/internal/httpapi"
|
||||
"git.vakhrushev.me/av/healthlog/internal/ingest"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
@@ -259,6 +260,28 @@ func TestHealthz(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Транспорт, не умеющий дедлайнов, приём не роняет: цена отказа здесь наивысшая
|
||||
// в проекте — доставка, не попавшая в архив, не попадает и в журнал.
|
||||
func TestПриёмРаботаетНаТранспортеБезДедлайнов(t *testing.T) {
|
||||
h, st := newAPI(t, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest",
|
||||
strings.NewReader(`{"data":{"metrics":[]}}`))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("статус = %d, ожидался 200", rec.Code)
|
||||
}
|
||||
n, err := st.CountDeliveries(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("CountDeliveries: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("доставок в учёте %d, ожидалась 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func newAPI(t *testing.T, writeTokens []string) (http.Handler, *store.Store) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
@@ -276,10 +299,13 @@ func newAPI(t *testing.T, writeTokens []string) (http.Handler, *store.Store) {
|
||||
|
||||
log := slog.New(slog.DiscardHandler)
|
||||
h := httpapi.New(httpapi.Options{
|
||||
Ingest: ingest.New(arch, st, fold.New(arch, st, 0, log), log),
|
||||
Ingest: ingest.New(arch, st, nil, log),
|
||||
Log: log,
|
||||
WriteTokens: writeTokens,
|
||||
MaxBodyMB: 1,
|
||||
// Бюджет задаётся всегда: httptest.ResponseRecorder дедлайнов не умеет,
|
||||
// и это ровно тот транспорт, на котором приём обязан продолжать работать.
|
||||
IngestWriteBudget: time.Minute,
|
||||
})
|
||||
return h, st
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/ingest"
|
||||
)
|
||||
@@ -22,6 +23,8 @@ type ingestResponse struct {
|
||||
// Код ответа отражает ДОСТАВКУ, а не разбор: 200 означает «тело сохранено в
|
||||
// архив», и этого достаточно, потому что разобрать сохранённое можно всегда.
|
||||
func (a *api) handleIngest(w http.ResponseWriter, r *http.Request) {
|
||||
a.extendWriteDeadline(w, r)
|
||||
|
||||
body, err := readBody(w, r, a.maxBody)
|
||||
if err != nil {
|
||||
writeReadError(w, err)
|
||||
@@ -46,6 +49,31 @@ func (a *api) handleIngest(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// extendWriteDeadline даёт маршруту приёма собственный бюджет ответа.
|
||||
//
|
||||
// `WriteTimeout` сервера ставится в `readRequest`, то есть ДО вызова
|
||||
// обработчика, и потому покрывает не только запись ответа, но и чтение тела:
|
||||
// при `read_timeout` в пять минут и `write_timeout` в тридцать секунд загрузка
|
||||
// длиннее тридцати секунд обрывается, а `read_timeout` при этом обещает пять
|
||||
// минут. Обрывается молча — обработчик ошибки записи не видит, а `accessLog`
|
||||
// пишет `status_code=200`.
|
||||
//
|
||||
// Лечится это здесь, а не подъёмом общего таймаута: длинный бюджет нужен
|
||||
// одному маршруту, и подъём снял бы защиту от застрявшей записи со всех
|
||||
// остальных.
|
||||
//
|
||||
// Транспорт, не умеющий дедлайнов, отказом приёма не является: приём —
|
||||
// единственное место, где поток вообще существует, и терять доставку из-за
|
||||
// неподдержанной оптимизации нельзя.
|
||||
func (a *api) extendWriteDeadline(w http.ResponseWriter, r *http.Request) {
|
||||
if a.ingestBudget <= 0 {
|
||||
return
|
||||
}
|
||||
if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(a.ingestBudget)); err != nil {
|
||||
a.log.DebugContext(r.Context(), "write deadline not set", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// metaFromHeaders достаёт то, что автоматизация Health Auto Export
|
||||
// рассказывает о себе своими заголовками. Именованные поля — те, по которым
|
||||
// ходят запросы; полный набор кладётся рядом, потому что документация HAE
|
||||
|
||||
Reference in New Issue
Block a user