Цена читающего маршрута: чекпойнт WAL по таймеру и условный запрос

- рядом с воркером свёртки живёт горутина, раз в минуту разбирающая журнал
  пассивным чекпойнтом; «журнал не разбирается» видно строкой владельцу, а не
  только по `df`. Признак — пара чисел, а не флаг занятости: тот молчит под
  удерживаемым читателем (`busy=0` при 6256 страницах и пяти перенесённых), а
  при занятой блокировке отдаёт `-1` вместо ответа, и `-1 >= -1` читалось бы как
  «разобрано целиком»
- каталог отвечает `304` на `If-None-Match`, не открывая снимок витрины. Метка
  собрана из всего, от чего зависит ответ: версии витрины (`data_version` с
  закреплённого соединения плюс поколение — значение локально для соединения и
  не переживает переоткрытия), горизонта измерения и области действия ресурса.
  Версия снимается до и после сборки: снятая после пометила бы устаревший снимок
  свежим номером
- предел и дедлайн ответа отложены в задачу Read API точек вместе с измеренной
  ценой первого запроса; попутно починен флаки-тест чужой задачи, искавший
  значение точки в сыром буфере записи лога
This commit is contained in:
av
2026-08-02 20:42:22 +03:00
parent 6b729bbd2f
commit 8db2ec7ff4
37 changed files with 3575 additions and 156 deletions
+53 -15
View File
@@ -9,6 +9,7 @@ import (
"net"
"net/http"
"os/signal"
"sync"
"syscall"
"time"
@@ -46,6 +47,31 @@ func runServe(args []string) error {
return serve(ctx, cfg, logging.New(cfg.Log.Level, cfg.Log.Format), nil)
}
// waitBackground ждёт выхода фоновых горутин и говорит, дождался ли.
//
// Отдельной функцией потому, что это единственная ветка остановки, у которой
// есть исход, и проверить её прогоном сервиса нельзя: бюджет — тридцать секунд,
// а заставить воркер зависнуть по требованию нечем.
//
// Не дождались — база НЕ закрывается: её транзакцию свернёт выход процесса, и
// доставка останется `pending`, то есть будет подобрана следующим стартом.
// Закрытая из-под воркера, она дала бы ERROR по доставке, с которой всё в
// порядке.
//
// Этап в записи называется общим именем, а не воркером свёртки: ждём мы двоих,
// и назвать виновным одного из них значило бы угадать. Чекпойнт при этом
// выходит по отмене немедленно, так что практически это всё тот же воркер, — но
// лог не должен утверждать того, чего не проверял.
func waitBackground(shutdownCtx context.Context, done <-chan struct{}, log *slog.Logger) bool {
select {
case <-done:
return true
case <-shutdownCtx.Done():
log.Warn("shutdown budget exceeded", "stage", "background")
return false
}
}
// serve поднимает сервис и ведёт его до отмены контекста.
//
// Контекст параметром, а не подпиской на сигнал внутри: иначе весь жизненный
@@ -119,23 +145,41 @@ func serve(ctx context.Context, cfg *config.Config, log *slog.Logger, ready func
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)
// Обе фоновые горутины живут на одном контексте и ждутся вместе. Вместе —
// потому что база закрывается ПОСЛЕ выхода обеих: закрытая из-под воркера,
// она даёт ERROR по доставке, с которой всё в порядке, а из-под чекпойнта —
// отказ обслуживания на ровном месте.
bgCtx, stopBackground := context.WithCancel(context.Background())
defer stopBackground()
var bg sync.WaitGroup
bg.Go(func() {
// Первый проход воркера и есть подбор неразобранного при старте:
// отдельного кода для него нет намеренно.
worker.Run(workerCtx)
worker.Run(bgCtx)
})
bg.Go(func() {
keepWAL(bgCtx, st, log, checkpointInterval)
})
backgroundDone := make(chan struct{})
go func() {
bg.Wait()
close(backgroundDone)
}()
errCh := make(chan error, 1)
go func() {
// Параметры обслуживания журнала — в той же строке, а не отдельной:
// горутина, которую забыли запустить, иначе неотличима от здоровой
// ровно до того дня, когда журнал упрётся в диск. Ноль новых строк, обе
// константы проверяемы глазами.
log.Info("server started",
"addr", ln.Addr().String(),
"db_path", cfg.Storage.DBPath,
"archive_dir", arch.Root(),
"max_body_mb", cfg.Ingest.MaxBodyMB)
"max_body_mb", cfg.Ingest.MaxBodyMB,
"wal_checkpoint_sec", int64(checkpointInterval.Seconds()),
"wal_limit_mb", store.JournalSizeLimitMB)
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- fmt.Errorf("serve: %w", err)
@@ -172,15 +216,9 @@ func serve(ctx context.Context, cfg *config.Config, log *slog.Logger, ready func
}
}
stopWorker()
select {
case <-workerDone:
stopBackground()
if waitBackground(shutdownCtx, backgroundDone, log) {
closeStore()
case <-shutdownCtx.Done():
// Воркер не вышел в бюджет. База не закрывается: её транзакцию свернёт
// выход процесса, и доставка останется `pending` — то есть будет
// подобрана следующим стартом.
log.Warn("shutdown budget exceeded", "stage", "fold-worker")
}
return serveErr
}