Приём отвечает 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
+98 -22
View File
@@ -5,6 +5,8 @@ import (
"errors"
"flag"
"fmt"
"log/slog"
"net"
"net/http"
"os/signal"
"syscall"
@@ -16,11 +18,13 @@ import (
"git.vakhrushev.me/av/healthlog/internal/httpapi"
"git.vakhrushev.me/av/healthlog/internal/ingest"
"git.vakhrushev.me/av/healthlog/internal/logging"
"git.vakhrushev.me/av/healthlog/internal/replay"
"git.vakhrushev.me/av/healthlog/internal/store"
)
// shutdownTimeout — сколько ждём завершения активных запросов при остановке.
// Приём может быть в середине записи многомегабайтного тела в архив.
// shutdownTimeout — общий бюджет остановки: сперва дожидаемся активных
// запросов, затем выхода воркера свёртки. Совпадает со `stop_grace_period`
// контейнера — за его пределом процесс всё равно убивают.
const shutdownTimeout = 30 * time.Second
func runServe(args []string) error {
@@ -34,16 +38,40 @@ func runServe(args []string) error {
if err != nil {
return err
}
log := logging.New(cfg.Log.Level, cfg.Log.Format)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
return serve(ctx, cfg, logging.New(cfg.Log.Level, cfg.Log.Format), nil)
}
// serve поднимает сервис и ведёт его до отмены контекста.
//
// Контекст параметром, а не подпиской на сигнал внутри: иначе весь жизненный
// цикл — порядок остановки, ожидание воркера, судьба несвёрнутой доставки —
// проверялся бы только посылкой сигнала самому себе, то есть не проверялся бы.
//
// ready, если задан, зовётся с ФАКТИЧЕСКИМ адресом прослушивания: при `:0` в
// конфиге узнать порт больше неоткуда.
func serve(ctx context.Context, cfg *config.Config, log *slog.Logger, ready func(addr string)) error {
st, err := store.Open(cfg.Storage.DBPath)
if err != nil {
return err
}
defer func() { _ = st.Close() }()
// Закрытие базы — не `defer`: при исчерпании бюджета остановки воркер может
// ещё сворачивать доставку, и закрытая из-под него база дала бы ERROR по
// доставке, с которой всё в порядке. Кто закрывает, решает ветка остановки.
closed := false
closeStore := func() {
if !closed {
closed = true
_ = st.Close()
}
}
arch, err := archive.New(cfg.Storage.ArchiveDir)
if err != nil {
closeStore()
return err
}
@@ -51,16 +79,23 @@ func runServe(args []string) error {
log.Warn("write auth disabled", "reason", "auth.write_tokens пуст")
}
handler := httpapi.New(httpapi.Options{
Ingest: ingest.New(arch, st, fold.New(arch, st, int64(cfg.Ingest.MaxBodyMB)<<20, log), log),
Log: log,
WriteTokens: cfg.Auth.WriteTokens,
MaxBodyMB: cfg.Ingest.MaxBodyMB,
})
// Воркер и приём делят одну свёртку: приём её только будит, сворачивает
// воркер — и в порядке журнала, чего синхронная свёртка внутри обработчика
// не давала при конкурентных доставках.
worker := replay.NewWorker(st, fold.New(arch, st, int64(cfg.Ingest.MaxBodyMB)<<20, log), log)
srv := &http.Server{
Addr: cfg.Server.Addr,
Handler: handler,
Handler: httpapi.New(httpapi.Options{
Ingest: ingest.New(arch, st, worker.Notify, log),
Log: log,
WriteTokens: cfg.Auth.WriteTokens,
MaxBodyMB: cfg.Ingest.MaxBodyMB,
// Бюджет ответа маршрута приёма: `WriteTimeout` сервера ставится ДО
// вызова обработчика и потому покрывает чтение тела, обрывая
// медленную загрузку молча. Длинный бюджет нужен одному маршруту,
// поэтому и выдаётся ему, а не всему серверу.
IngestWriteBudget: cfg.Server.ReadTimeout.D() + cfg.Server.WriteTimeout.D(),
}),
// ReadTimeout щедрый (большой пакет по мобильной сети), но заголовки
// обязаны приехать быстро — иначе полуоткрытое соединение держит слот.
ReadHeaderTimeout: 10 * time.Second,
@@ -68,33 +103,74 @@ func runServe(args []string) error {
WriteTimeout: cfg.Server.WriteTimeout.D(),
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
ln, err := net.Listen("tcp", cfg.Server.Addr)
if err != nil {
closeStore()
return fmt.Errorf("listen %q: %w", cfg.Server.Addr, err)
}
workerCtx, stopWorker := context.WithCancel(context.Background())
defer stopWorker()
workerDone := make(chan struct{})
go func() {
defer close(workerDone)
// Первый проход воркера и есть подбор неразобранного при старте:
// отдельного кода для него нет намеренно.
worker.Run(workerCtx)
}()
errCh := make(chan error, 1)
go func() {
log.Info("server started",
"addr", cfg.Server.Addr,
"addr", ln.Addr().String(),
"db_path", cfg.Storage.DBPath,
"archive_dir", arch.Root(),
"max_body_mb", cfg.Ingest.MaxBodyMB)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- fmt.Errorf("listen: %w", err)
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- fmt.Errorf("serve: %w", err)
}
}()
if ready != nil {
ready(ln.Addr().String())
}
var serveErr error
select {
case err := <-errCh:
return err
case serveErr = <-errCh:
// Отказ приёма не отменяет остановки воркера: закрыть базу, не дождавшись
// его, значит выдернуть её из-под идущей свёртки и получить ERROR по
// доставке, с которой всё в порядке. Ошибка не логируется здесь — она
// возвращается наверх, и логирует её один раз вызывающий.
case <-ctx.Done():
log.Info("server stopping")
}
log.Info("server stopping")
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
// Приём прекращается РАНЬШЕ воркера: обратный порядок оставил бы доставки,
// принятые после его остановки, никого не разбудившими.
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown: %w", err)
switch {
case errors.Is(err, context.DeadlineExceeded):
// Исчерпание бюджета Shutdown возвращает штатно, и отказом это не
// является: приём мог дочитывать многомегабайтное тело.
log.Warn("shutdown budget exceeded", "stage", "http")
case serveErr == nil:
serveErr = fmt.Errorf("shutdown: %w", err)
}
}
return nil
stopWorker()
select {
case <-workerDone:
closeStore()
case <-shutdownCtx.Done():
// Воркер не вышел в бюджет. База не закрывается: её транзакцию свернёт
// выход процесса, и доставка останется `pending` — то есть будет
// подобрана следующим стартом.
log.Warn("shutdown budget exceeded", "stage", "fold-worker")
}
return serveErr
}