Приём отвечает 200 до свёртки, свёртку ведёт фоновый воркер

- Очередью служит сама таблица: доставка ждёт свёртки в статусе `pending`,
  канал несёт только бит «есть работа». Переполнять нечего, падение процесса
  очередь не теряет, а подбор `pending` при старте — обычный проход воркера, а
  не отдельный код. Классификация исхода общая с пересборкой журнала.
- Исход разбора начал отражать доставку, а не обстоятельства: отмена и
  занятость базы статус не меняют (иначе конкуренция за базу выводила бы
  доставку из очереди навсегда), паника свёртки больше не валит процесс, а
  учёт доставки идёт через транзакцию с повторами.
- Длинный бюджет ответа выдан маршруту приёма, а не всему серверу:
  `write_timeout` в Go покрывает и чтение тела, и общий подъём снял бы защиту с
  остальных маршрутов.
This commit is contained in:
av
2026-08-02 11:01:42 +03:00
parent ebd59af056
commit 63bffe2865
46 changed files with 3561 additions and 296 deletions
+14 -8
View File
@@ -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()
+28 -2
View File
@@ -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
}
+28
View File
@@ -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