хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
@@ -7,8 +7,9 @@
|
||||
// тихо кладёт инструмент разработчика в боевой образ. Один пакет платит эти
|
||||
// четыре места **однажды**, сколько бы подкоманд в нём ни завелось.
|
||||
//
|
||||
// Сегодня подкоманда одна — `proxy`. Заведение владельца панели придёт
|
||||
// подкомандой `admin` вместе с задачей о запуске одной командой.
|
||||
// Подкоманд две: `proxy` — подставной обратный прокси, `resume` — возврат
|
||||
// остановленной записи в работу. Вторая встала на место панели владельца:
|
||||
// панели у сервиса больше нет, а экраны правки записи приносят отдельные задачи.
|
||||
//
|
||||
// Имена заголовков берутся **константами транспорта**, а не литералами: они
|
||||
// нормативны, и второй список разошёлся бы с первым молча — локальный вход
|
||||
@@ -43,6 +44,8 @@ func main() {
|
||||
switch os.Args[1] {
|
||||
case "proxy":
|
||||
runProxy(os.Args[2:])
|
||||
case "resume":
|
||||
runResume(os.Args[2:])
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "неизвестная подкоманда: %s\n\n", os.Args[1])
|
||||
usage()
|
||||
@@ -54,7 +57,8 @@ func usage() {
|
||||
fmt.Fprint(os.Stderr, `Оснастка разработчика.
|
||||
|
||||
Подкоманды:
|
||||
proxy подставной обратный прокси: ставит заголовок и шлёт запрос сервису
|
||||
proxy подставной обратный прокси: ставит заголовок и шлёт запрос сервису
|
||||
resume вернуть остановленную запись в работу
|
||||
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
|
||||
"git.vakhrushev.me/av/transcriber/internal/config"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||
)
|
||||
|
||||
// stepResume — чем возврат в работу подписывается в журнале событий записи.
|
||||
const stepResume = "resume"
|
||||
|
||||
// runResume возвращает остановленную запись в работу.
|
||||
//
|
||||
// Подкоманда встала на место панели владельца: панели у сервиса больше нет, а
|
||||
// экраны правки записи приносят отдельные задачи. Из всего, что владелец делал
|
||||
// панелью, отложить до экранов нельзя было одно — возврат остановленной записи.
|
||||
//
|
||||
// **Колонок подкоманда не пишет.** Перечень полей, которые возврат обязан
|
||||
// сбросить — признак остановки, признак захвата и срок его протухания, число
|
||||
// отказов, паузу и время входа в рубеж, — исполняет домен одним действием.
|
||||
// Рука, забывшая любое из них, оставила бы запись либо невидимой для захвата,
|
||||
// либо останавливаемой снова первым же захватом — молча, без единой строки.
|
||||
//
|
||||
// Событие журнала записи пишется с происхождением «человек»: иначе запись,
|
||||
// побывавшая остановленной и вернувшаяся в работу, неотличима в журнале от
|
||||
// записи, которую конвейер вёл без остановок, а происхождение события перестаёт
|
||||
// различать что-либо.
|
||||
func runResume(args []string) {
|
||||
flags := flag.NewFlagSet("resume", flag.ExitOnError)
|
||||
configPath := flags.String("c", "config.toml", "путь к файлу настроек")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
if flags.NArg() != 1 {
|
||||
fmt.Fprint(os.Stderr, "укажи идентификатор записи: devtools resume [-c config.toml] <id>\n")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
recordID, ok := ident.Parse(flags.Arg(0))
|
||||
if !ok {
|
||||
log.Fatalf("идентификатор записи не читается: %q", flags.Arg(0))
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(*configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("настройки не читаются: %v", err)
|
||||
}
|
||||
if err := cfg.Storage.Validate(); err != nil {
|
||||
log.Fatalf("настройки хранилища негодны: %v", err)
|
||||
}
|
||||
|
||||
db, err := sqliterepo.Open(cfg.Storage.DataDir, sqliterepo.Settings{
|
||||
BusyTimeoutMs: cfg.Storage.BusyTimeoutMs,
|
||||
ReadConnections: cfg.Storage.ReadConnections,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("база не открывается: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := db.Close(); err != nil {
|
||||
log.Printf("база закрылась с отказом: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
records := sqliterepo.NewAudioRecordRepository(db)
|
||||
events := sqliterepo.NewRecordEventRepository(db)
|
||||
|
||||
record, err := records.Get(recordID)
|
||||
if err != nil {
|
||||
log.Fatalf("запись не читается: %v", err)
|
||||
}
|
||||
if !record.IsHalted() {
|
||||
log.Fatalf("запись %s не остановлена: возвращать в работу нечего", recordID)
|
||||
}
|
||||
|
||||
// Захват снимает сам домен, поэтому сохранение идёт **безусловным**: держателя
|
||||
// у остановленной записи нет, и сверять признак захвата не с чем.
|
||||
record.Resume()
|
||||
if err := records.Save(record, ""); err != nil {
|
||||
log.Fatalf("запись не сохраняется: %v", err)
|
||||
}
|
||||
|
||||
if err := events.Append(&entity.RecordEvent{
|
||||
RecordID: recordID,
|
||||
Origin: entity.EventOriginHuman,
|
||||
Step: stepResume,
|
||||
Outcome: entity.EventOutcomeResumed,
|
||||
}); err != nil {
|
||||
log.Fatalf("событие журнала записи не сохраняется: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("запись %s возвращена в работу с рубежа %s", recordID, record.State)
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
|
||||
)
|
||||
|
||||
// Перечень адресного пространства для проверок журнала. Обработчиков он здесь
|
||||
// не вешает: журналу нужны только границы, а не то, что стоит за ними.
|
||||
func journalMounts() []httpcontroller.Mount {
|
||||
return []httpcontroller.Mount{
|
||||
{Path: httpcontroller.StorageRoot},
|
||||
{Path: httpcontroller.PanelRoot},
|
||||
{Path: httpcontroller.AppRoot},
|
||||
{Path: httpcontroller.HealthPath, Exact: true},
|
||||
{Path: httpcontroller.MetricsPath, Exact: true},
|
||||
}
|
||||
}
|
||||
|
||||
// Имя файла в хранилище в журнал не идёт: оно последняя часть ссылки
|
||||
// `/api/files/...`, и строка журнала вместе с идентификатором записи собрала бы
|
||||
// ссылку целиком. Инвариант проекта, critical.
|
||||
func TestJournalRouteHidesStoredFileName(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ссылка на файл теряет имя",
|
||||
path: "/api/files/files/abc123def456ghi/9f1c-3b2a.mp3",
|
||||
want: "/api/files/files/abc123def456ghi/<имя>",
|
||||
},
|
||||
{
|
||||
name: "маршрут остаётся различимым",
|
||||
path: "/api/files/files/abc123def456ghi/запись.ogg",
|
||||
want: "/api/files/files/abc123def456ghi/<имя>",
|
||||
},
|
||||
{
|
||||
name: "прочие пути не трогаются",
|
||||
path: "/app/audiorecords/abc123def456ghi",
|
||||
want: "/app/audiorecords/abc123def456ghi",
|
||||
},
|
||||
{
|
||||
name: "приём не трогается",
|
||||
path: "/app/audiorecords",
|
||||
want: "/app/audiorecords",
|
||||
},
|
||||
{
|
||||
name: "сам префикс без имени не портится",
|
||||
path: "/api/files/",
|
||||
want: "/api/files/",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
assert.Equal(t, c.want, journalRoute(c.path, journalMounts()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Отдельно и прямо: имени в готовой строке нет. Проверка судит результат, а не
|
||||
// устройство — переписанная реализация обязана остаться зелёной.
|
||||
func TestJournalRouteDropsNameEntirely(t *testing.T) {
|
||||
const stored = "0f7b8dd3-d1cc-424c.mp3"
|
||||
|
||||
route := journalRoute("/api/files/files/rec0000000000000/"+stored, journalMounts())
|
||||
|
||||
assert.NotContains(t, route, stored, "имя файла в хранилище не доезжает до журнала")
|
||||
assert.Contains(t, route, "rec0000000000000", "идентификатор записи остаётся: по нему прослеживается путь")
|
||||
}
|
||||
|
||||
// Путь, не принадлежащий сервису, уходит приложению и в журнал дословно не
|
||||
// идёт: множеством его значений распоряжается спрашивающий.
|
||||
func TestJournalRouteHidesWebappPath(t *testing.T) {
|
||||
cases := []string{
|
||||
"/",
|
||||
"/records/abc123def456ghi",
|
||||
"/" + strings.Repeat("a", 1024),
|
||||
}
|
||||
|
||||
for _, path := range cases {
|
||||
route := journalRoute(path, journalMounts())
|
||||
|
||||
assert.Equal(t, webappRoute, route)
|
||||
}
|
||||
}
|
||||
+85
-188
@@ -9,29 +9,31 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
ffmpegconv "git.vakhrushev.me/av/transcriber/internal/adapter/converter/ffmpeg"
|
||||
ffmpegmv "git.vakhrushev.me/av/transcriber/internal/adapter/metaviewer/ffmpeg"
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer/yandex"
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
|
||||
"git.vakhrushev.me/av/transcriber/internal/config"
|
||||
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
|
||||
"git.vakhrushev.me/av/transcriber/internal/controller/worker"
|
||||
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||
"git.vakhrushev.me/av/transcriber/web"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
)
|
||||
|
||||
// main держит одну обязанность: отказ подъёма пишется **одной** строкой и
|
||||
// кончается ненулевым кодом выхода.
|
||||
//
|
||||
// Работа вынесена в run, чтобы уборка шла отложенными вызовами: `os.Exit`
|
||||
// посреди подъёма оставил бы за собой открытые пулы базы и незакрытого клиента
|
||||
// распознавания.
|
||||
func main() {
|
||||
// Создаем структурированный логгер
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
@@ -39,34 +41,36 @@ func main() {
|
||||
}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
if err := run(logger); err != nil {
|
||||
logger.Error("Transcriber service failed to start", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(logger *slog.Logger) error {
|
||||
// Parse command line flags
|
||||
configPath := flag.String("c", "config.toml", "Path to config file")
|
||||
flag.StringVar(configPath, "config", "config.toml", "Path to config file (alias for -c)")
|
||||
flag.Parse()
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.LoadConfig(*configPath)
|
||||
if err != nil {
|
||||
logger.Error("Unable to load configuration", "config_path", *configPath, "error", err)
|
||||
os.Exit(1)
|
||||
} else {
|
||||
logger.Info("Configuration loaded successfully", "config_path", *configPath)
|
||||
return fmt.Errorf("unable to load configuration from %s: %w", *configPath, err)
|
||||
}
|
||||
logger.Info("Configuration loaded successfully", "config_path", *configPath)
|
||||
|
||||
// Пустой перечень доверенных адресов роняет старт: он значит «не верить
|
||||
// никому», то есть сервис, поднявшийся никого не узнающим, — и узнать об
|
||||
// этом было бы неоткуда.
|
||||
if err := cfg.Auth.Validate(); err != nil {
|
||||
logger.Error("Unable to start without trusted proxies", "error", err)
|
||||
os.Exit(1)
|
||||
return err
|
||||
}
|
||||
|
||||
// Перечень разобран один раз, при старте: разбирать строки на каждом запросе
|
||||
// значило бы платить за настройку, которая не меняется.
|
||||
trustedNetworks, err := cfg.Auth.TrustedNetworks()
|
||||
if err != nil {
|
||||
logger.Error("Unable to read trusted proxies", "error", err)
|
||||
os.Exit(1)
|
||||
return err
|
||||
}
|
||||
// Перечень называется строкой журнала: сервис, никого не узнающий из-за
|
||||
// неверного перечня, иначе неотличим от сервиса, до которого заголовок не
|
||||
@@ -77,8 +81,10 @@ func main() {
|
||||
// отрицательное число и нулевой предел простоя — опечатка, и подниматься с
|
||||
// ней значит остановить всякую запись первым же захватом.
|
||||
if err := cfg.Pipeline.Validate(); err != nil {
|
||||
logger.Error("Unable to start with incorrect pipeline settings", "error", err)
|
||||
os.Exit(1)
|
||||
return err
|
||||
}
|
||||
if err := cfg.Storage.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Загружаем переменные окружения из .env файла
|
||||
@@ -86,32 +92,43 @@ func main() {
|
||||
logger.Warn("Warning: .env file not found, using system environment variables")
|
||||
}
|
||||
|
||||
// Хранилище поднимается библиотекой, а не её набором команд: разбор флагов
|
||||
// и мягкая остановка остаются нашими. Схему накатывает Serve — он гоняет
|
||||
// непринятые шаги прежде, чем поднять сервер.
|
||||
storage, err := pbrepo.New(cfg.Storage.DataDir)
|
||||
db, err := sqliterepo.Open(cfg.Storage.DataDir, sqliterepo.Settings{
|
||||
BusyTimeoutMs: cfg.Storage.BusyTimeoutMs,
|
||||
ReadConnections: cfg.Storage.ReadConnections,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("Failed to open storage", "error", err)
|
||||
os.Exit(1)
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := storage.ResetBootstrapState(); err != nil {
|
||||
logger.Error("Failed to close storage", "error", err)
|
||||
if err := db.Close(); err != nil {
|
||||
logger.Error("Failed to close the database", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
pbrepo.BindPanelRules(storage)
|
||||
// Создаем контекст для graceful shutdown
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Создаем репозитории
|
||||
recordRepo := pbrepo.NewAudioRecordRepository(storage)
|
||||
// Схема накатывается **до** подъёма входов и до старта воркеров, а её отказ
|
||||
// роняет старт: сервис, поднявшийся на неприведённой схеме, отвечает отказом
|
||||
// на каждый запрос и на каждый прогон воркера — вместо одной строки о
|
||||
// причине их становятся сотни.
|
||||
if err := sqliterepo.Migrate(ctx, db, cfg.Storage.DataDir, logger); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
store := sqliterepo.NewStore(cfg.Storage.DataDir)
|
||||
recordRepo := sqliterepo.NewAudioRecordRepository(db)
|
||||
fileRepo := sqliterepo.NewFileRepository(db, store)
|
||||
repos := service.Repositories{
|
||||
Records: recordRepo,
|
||||
Files: pbrepo.NewFileRepository(storage),
|
||||
Texts: pbrepo.NewTextRepository(storage),
|
||||
Structures: pbrepo.NewStructureRepository(storage),
|
||||
Recognitions: pbrepo.NewRecognitionRepository(storage),
|
||||
Events: pbrepo.NewRecordEventRepository(storage),
|
||||
Files: fileRepo,
|
||||
Texts: sqliterepo.NewTextRepository(db),
|
||||
Structures: sqliterepo.NewStructureRepository(db),
|
||||
Recognitions: sqliterepo.NewRecognitionRepository(db, store),
|
||||
Events: sqliterepo.NewRecordEventRepository(db),
|
||||
}
|
||||
users := sqliterepo.NewUserRepository(db)
|
||||
|
||||
// Создаем адаптеры
|
||||
metaviewer := ffmpegmv.NewFfmpegMetaViewer()
|
||||
@@ -127,8 +144,7 @@ func main() {
|
||||
FolderID: cfg.Yandex.FolderID,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("failed to create audio recognizer", "error", err)
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("failed to create audio recognizer: %w", err)
|
||||
}
|
||||
// Отдавать отказ закрытия некому — процесс заканчивается, — поэтому он идёт
|
||||
// в журнал владельца. Что он означает: gRPC-клиент отдаёт здесь отказ лишь
|
||||
@@ -140,7 +156,6 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Создаем сервисы
|
||||
transcribeService := service.NewTranscribeService(
|
||||
repos,
|
||||
metaviewer,
|
||||
@@ -150,10 +165,6 @@ func main() {
|
||||
logger,
|
||||
)
|
||||
|
||||
// Создаем контекст для graceful shutdown
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Создаем WaitGroup для ожидания завершения всех воркеров
|
||||
var wg sync.WaitGroup
|
||||
|
||||
@@ -166,113 +177,41 @@ func main() {
|
||||
pool.Start(ctx)
|
||||
}()
|
||||
|
||||
// Вход у сервиса один — приём по HTTP, — и метка ставится только ему. Метки
|
||||
// убранного входа Telegram здесь нет намеренно: ноль читался бы как поломка,
|
||||
// а признак существует ради того дня, когда входов снова станет больше.
|
||||
// Вход у сервиса один — приём по HTTP, — и метка ставится только ему.
|
||||
metrics.IntakeUpGauge.WithLabelValues("http").Set(1)
|
||||
|
||||
// Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
|
||||
// и второму серверу на нём взяться неоткуда.
|
||||
appHandler := httpcontroller.NewAppHandler(recordRepo, repos.Texts, repos.Structures, transcribeService, logger)
|
||||
appHandler := httpcontroller.NewAppHandler(
|
||||
recordRepo, repos.Texts, repos.Structures, fileRepo, transcribeService, logger,
|
||||
)
|
||||
|
||||
// Адресное пространство сервиса объявлено одним перечнем, и он порождает
|
||||
// регистрацию, а не описывает её: корень, заведённый мимо перечня, не
|
||||
// получит обработчика вовсе. Отсюда же уровень журнала для адресов
|
||||
// наблюдения и правило неизвестного пути у раздачи приложения.
|
||||
mounts := httpcontroller.ServiceMounts(appHandler, promhttp.Handler())
|
||||
// наблюдения, правило неизвестного пути у раздачи приложения и область
|
||||
// действия узнавания.
|
||||
mounts := httpcontroller.ServiceMounts(
|
||||
httpcontroller.AppChain(appHandler.Routes(), users, trustedNetworks, logger),
|
||||
promhttp.Handler(),
|
||||
)
|
||||
|
||||
dist, appBuilt := web.Dist()
|
||||
webappHandler := httpcontroller.NewWebappHandler(dist, appBuilt, mounts, logger)
|
||||
webappHandler := httpcontroller.NewWebappHandler(dist, appBuilt, logger)
|
||||
|
||||
// Сервер приезжает каналом, а не общей переменной: хук исполняется в
|
||||
// горутине сервера, а читает его горутина остановки, и связи «произошло
|
||||
// раньше» между ними иначе нет.
|
||||
srvCh := make(chan *http.Server, 1)
|
||||
storage.OnServe().BindFunc(func(se *core.ServeEvent) error {
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
|
||||
Handler: httpcontroller.BuildHandler(mounts, webappHandler, logger),
|
||||
// Шесть часов записи по медленному каналу переживают любой фиксированный
|
||||
// таймаут чтения, а умолчание хранилища — пять минут. Стойкость к
|
||||
// целенаправленной нагрузке объявлена вне модели угроз проекта.
|
||||
se.Server.ReadTimeout = 0
|
||||
srvCh <- se.Server
|
||||
// таймаут чтения. Стойкость к целенаправленной нагрузке объявлена вне
|
||||
// модели угроз проекта.
|
||||
ReadTimeout: 0,
|
||||
}
|
||||
|
||||
// Журнал входящих запросов вернулся своим слоем: вместе с gin ушёл
|
||||
// `sloggin`, а хранилище пишет запросы в свою таблицу, которой в
|
||||
// журнале контейнера не видно. Поля — те, что просит конвенция.
|
||||
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
||||
start := clock.Start()
|
||||
err := e.Next()
|
||||
|
||||
level := slog.LevelInfo
|
||||
if httpcontroller.IsObservationAddress(mounts, e.Request.URL.Path) {
|
||||
// Опрос здоровья и метрик идёт постоянно и полезного не несёт.
|
||||
level = slog.LevelDebug
|
||||
}
|
||||
|
||||
attrs := []any{
|
||||
"http.method", e.Request.Method,
|
||||
"http.route", journalRoute(e.Request.URL.Path, mounts),
|
||||
"http.status_code", e.Status(),
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
"transport", "http",
|
||||
}
|
||||
|
||||
// Путь, отданный приложению, в журнал не идёт — вместо него исход
|
||||
// и длина: по ним видно, что происходит, а множеством значений
|
||||
// самого пути распоряжается спрашивающий.
|
||||
if outcome := httpcontroller.WebappOutcome(e); outcome != "" {
|
||||
attrs = append(attrs,
|
||||
"webapp.outcome", outcome,
|
||||
"http.path_length", len(e.Request.URL.Path))
|
||||
}
|
||||
|
||||
logger.Log(e.Request.Context(), level, "Incoming request", attrs...)
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
// Слой узнавания вешается на **корневой** роутер, а не под корнем
|
||||
// приложения: он накрывает ещё и адрес выдачи файлового токена, который
|
||||
// принадлежит роутеру хранилища и группой не накрывается. Область его
|
||||
// действия при этом выводится из перечня адресного пространства — см.
|
||||
// `underIdentifiedArea`, — а не из места привязки.
|
||||
se.Router.Bind(httpcontroller.TrustedHeaderIdentity(storage, mounts, trustedNetworks, logger))
|
||||
|
||||
// Своё правило ограничителя частоты под корень приложения. Правило
|
||||
// хранилища настроено на его собственный корень и наших адресов больше не
|
||||
// покрывает: вместе с переездом приложения ограничитель перестал бы
|
||||
// существовать для него вовсе, и заметить это было бы нечем.
|
||||
if err := httpcontroller.ApplyAppRateLimit(storage); err != nil {
|
||||
return fmt.Errorf("failed to apply app rate limit: %w", err)
|
||||
}
|
||||
|
||||
// Хранилищу называется заголовок, из которого брать адрес
|
||||
// спрашивающего. Без этого счётчик ограничителя ключуется адресом пира,
|
||||
// а пир теперь всегда один — прокси, и бюджет становится общим на всех.
|
||||
if err := httpcontroller.ApplyTrustedProxyHeaders(storage); err != nil {
|
||||
return fmt.Errorf("failed to apply trusted proxy headers: %w", err)
|
||||
}
|
||||
|
||||
httpcontroller.RegisterServiceRoutes(se.Router, mounts)
|
||||
|
||||
// Раздача приложения вешается последней: она занимает корень, и всё,
|
||||
// что не совпало ни с одним адресом сервиса, доходит до неё.
|
||||
webappHandler.Register(se.Router)
|
||||
|
||||
return se.Next()
|
||||
})
|
||||
|
||||
// Запускаем HTTP сервер в отдельной горутине
|
||||
serveErr := make(chan error, 1)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
logger.Info("Starting HTTP server", "port", cfg.Server.Port)
|
||||
err := apis.Serve(storage, apis.ServeConfig{
|
||||
HttpAddr: fmt.Sprintf(":%d", cfg.Server.Port),
|
||||
ShowStartBanner: false,
|
||||
})
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.Error("HTTP server error", "error", err)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
serveErr <- err
|
||||
}
|
||||
}()
|
||||
@@ -285,41 +224,36 @@ func main() {
|
||||
logger.Info("Press Ctrl+C to stop...")
|
||||
|
||||
// Ждем сигнал завершения либо отказ сервера
|
||||
var startupErr error
|
||||
select {
|
||||
case <-sigChan:
|
||||
logger.Info("Received shutdown signal, initiating graceful shutdown...")
|
||||
case <-serveErr:
|
||||
logger.Error("HTTP server stopped unexpectedly, shutting down")
|
||||
case err := <-serveErr:
|
||||
logger.Error("HTTP server stopped unexpectedly, shutting down", "error", err)
|
||||
startupErr = err
|
||||
}
|
||||
|
||||
// Создаем контекст с таймаутом для graceful shutdown HTTP сервера
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Duration(cfg.Server.ShutdownTimeout)*time.Second)
|
||||
// Останавливаем HTTP сервер
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(
|
||||
context.Background(), time.Duration(cfg.Server.ShutdownTimeout)*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
// Останавливаем HTTP сервер
|
||||
select {
|
||||
case srv := <-srvCh:
|
||||
logger.Info("Shutting down HTTP server...")
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Error("HTTP server forced to shutdown", "error", err)
|
||||
} else {
|
||||
logger.Info("HTTP server stopped gracefully")
|
||||
}
|
||||
default:
|
||||
logger.Info("HTTP server was not started, nothing to shut down")
|
||||
logger.Info("Shutting down HTTP server...")
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Error("HTTP server forced to shutdown", "error", err)
|
||||
} else {
|
||||
logger.Info("HTTP server stopped gracefully")
|
||||
}
|
||||
|
||||
// Отменяем контекст для остановки воркеров
|
||||
cancel()
|
||||
|
||||
// Создаем канал для уведомления о завершении всех воркеров
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Ждем завершения всех воркеров или таймаута
|
||||
select {
|
||||
case <-done:
|
||||
logger.Info("All workers stopped gracefully")
|
||||
@@ -328,43 +262,6 @@ func main() {
|
||||
}
|
||||
|
||||
logger.Info("Transcriber service stopped")
|
||||
}
|
||||
|
||||
// filesPathPrefix — начало пути, которым хранилище отдаёт файл записи. Последний
|
||||
// сегмент такого пути и есть имя файла в хранилище.
|
||||
const filesPathPrefix = "/api/files/"
|
||||
|
||||
// webappRoute — чем в журнале обозначается всякий путь, отданный приложению.
|
||||
const webappRoute = "<приложение>"
|
||||
|
||||
// journalRoute готовит путь запроса к записи в журнал.
|
||||
//
|
||||
// Инвариант проекта запрещает имени файла в хранилище попадать в журнал: имя —
|
||||
// последняя часть ссылки `/api/files/...`, и строка журнала вместе с
|
||||
// идентификатором записи собирала бы ссылку целиком. Слой журнала пишет путь
|
||||
// всякого запроса, поэтому имя срезается здесь — иначе оно уезжало бы в
|
||||
// собранные логи при каждом скачивании записи.
|
||||
//
|
||||
// Срезается только имя: маршрут остаётся различимым, и наблюдаемость от этого не
|
||||
// теряется.
|
||||
//
|
||||
// Путь, не принадлежащий сервису, в журнал не идёт вовсе. До появления раздачи
|
||||
// приложения такой путь ловил отказ маршрутизатора, а теперь получает разметку
|
||||
// с кодом `200`: множеством его значений распоряжается спрашивающий, и
|
||||
// дословная строка сделала бы журнал местом, куда аноним пишет свой текст.
|
||||
func journalRoute(path string, mounts []httpcontroller.Mount) string {
|
||||
if !httpcontroller.IsServiceAddress(mounts, path) {
|
||||
return webappRoute
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(path, filesPathPrefix) {
|
||||
return path
|
||||
}
|
||||
|
||||
cut := strings.LastIndex(path, "/")
|
||||
if cut < len(filesPathPrefix) {
|
||||
return path
|
||||
}
|
||||
|
||||
return path[:cut+1] + "<имя>"
|
||||
return startupErr
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user