хранилище, файлы записей и очередь переведены на встроенную PocketBase
- записи, метаданные и файлы съехались под один каталог данных; появилась панель владельца, а gin, goqu, goose и требование CGO ушли - захват задачи стал одним запросом с RETURNING; заведены число попыток, состояние dead и нарастающая пауза вместо признака is_error - имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с идентификатором записи оно собирало бы ссылку на скачивание
This commit is contained in:
@@ -2,8 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -17,29 +16,17 @@ import (
|
||||
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"
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/telegram"
|
||||
"git.vakhrushev.me/av/transcriber/internal/config"
|
||||
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
|
||||
tgcontroller "git.vakhrushev.me/av/transcriber/internal/controller/tg"
|
||||
"git.vakhrushev.me/av/transcriber/internal/controller/worker"
|
||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
_ "github.com/doug-martin/goqu/v9/dialect/sqlite3"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/joho/godotenv"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/pressly/goose/v3"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
sloggin "github.com/samber/slog-gin"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
const (
|
||||
ServerShutdownTimeout = 5
|
||||
ForceShutdownTimeout = 20
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -68,35 +55,25 @@ func main() {
|
||||
logger.Warn("Warning: .env file not found, using system environment variables")
|
||||
}
|
||||
|
||||
// Создаем директории если они не существуют
|
||||
if err := os.MkdirAll(cfg.Storage.Path, 0750); err != nil {
|
||||
logger.Error("Failed to create file storage directory", "path", cfg.Storage.Path, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite3", cfg.Database.Path)
|
||||
// Хранилище поднимается библиотекой, а не её набором команд: разбор флагов
|
||||
// и мягкая остановка остаются нашими. Схему накатывает Serve — он гоняет
|
||||
// непринятые шаги прежде, чем поднять сервер.
|
||||
storage, err := pbrepo.New(cfg.Storage.DataDir)
|
||||
if err != nil {
|
||||
logger.Error("failed to open database", "error", err)
|
||||
logger.Error("Failed to open storage", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
defer func() {
|
||||
if err := storage.ResetBootstrapState(); err != nil {
|
||||
logger.Error("Failed to close storage", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
logger.Error("failed to ping database", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
gq := goqu.New("sqlite3", db)
|
||||
|
||||
// Запускаем миграции
|
||||
if err := RunMigrations(db, logger); err != nil {
|
||||
logger.Error("Failed to run migrations", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
pbrepo.BindPanelRules(storage)
|
||||
|
||||
// Создаем репозитории
|
||||
fileRepo := sqlite.NewFileRepository(db, gq)
|
||||
jobRepo := sqlite.NewTranscriptJobRepository(db, gq)
|
||||
fileRepo := pbrepo.NewFileRepository(storage)
|
||||
jobRepo := pbrepo.NewTranscriptJobRepository(storage)
|
||||
|
||||
// Создаем адаптеры
|
||||
metaviewer := ffmpegmv.NewFfmpegMetaViewer()
|
||||
@@ -139,7 +116,6 @@ func main() {
|
||||
converter,
|
||||
recognizer,
|
||||
tgSender,
|
||||
cfg.Storage.Path,
|
||||
logger,
|
||||
)
|
||||
|
||||
@@ -193,49 +169,74 @@ func main() {
|
||||
}(w)
|
||||
}
|
||||
|
||||
// Создаем Gin middleware для логирования
|
||||
gin.SetMode(gin.DebugMode)
|
||||
router := gin.New()
|
||||
router.Use(sloggin.New(logger))
|
||||
router.Use(gin.Recovery())
|
||||
// Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом,
|
||||
// и второму серверу на нём взяться неоткуда.
|
||||
transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService, logger)
|
||||
|
||||
// Запускаем HTTP сервер для API (создание задач и проверка статуса)
|
||||
transcribeHandler := httpcontroller.NewTranscribeHandler(jobRepo, transcribeService)
|
||||
// Сервер приезжает каналом, а не общей переменной: хук исполняется в
|
||||
// горутине сервера, а читает его горутина остановки, и связи «произошло
|
||||
// раньше» между ними иначе нет.
|
||||
srvCh := make(chan *http.Server, 1)
|
||||
storage.OnServe().BindFunc(func(se *core.ServeEvent) error {
|
||||
// Шесть часов записи по медленному каналу переживают любой фиксированный
|
||||
// таймаут чтения, а умолчание хранилища — пять минут. Стойкость к
|
||||
// целенаправленной нагрузке объявлена вне модели угроз проекта.
|
||||
se.Server.ReadTimeout = 0
|
||||
srvCh <- se.Server
|
||||
|
||||
// Настраиваем роуты только для создания задач и проверки статуса
|
||||
api := router.Group("/api")
|
||||
{
|
||||
api.POST("/audio", transcribeHandler.CreateTranscribeJob)
|
||||
api.GET("/status/:id", transcribeHandler.GetTranscribeJobStatus)
|
||||
}
|
||||
// Журнал входящих запросов вернулся своим слоем: вместе с gin ушёл
|
||||
// `sloggin`, а хранилище пишет запросы в свою таблицу, которой в
|
||||
// журнале контейнера не видно. Поля — те, что просит конвенция.
|
||||
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
||||
start := time.Now()
|
||||
err := e.Next()
|
||||
|
||||
// Добавляем middleware для обработки больших файлов
|
||||
router.MaxMultipartMemory = 32 << 20 // 32 MiB
|
||||
level := slog.LevelInfo
|
||||
if e.Request.URL.Path == "/health" || e.Request.URL.Path == "/metrics" {
|
||||
// Опрос здоровья и метрик идёт постоянно и полезного не несёт.
|
||||
level = slog.LevelDebug
|
||||
}
|
||||
|
||||
// Добавляем базовый роут для проверки работоспособности
|
||||
router.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"status": "ok",
|
||||
"message": "Transcriber service is running",
|
||||
logger.Log(e.Request.Context(), level, "Incoming request",
|
||||
"http.method", e.Request.Method,
|
||||
"http.route", e.Request.URL.Path,
|
||||
"http.status_code", e.Status(),
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
"transport", "http")
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
transcribeHandler.Register(se.Router)
|
||||
|
||||
se.Router.GET("/health", func(e *core.RequestEvent) error {
|
||||
return e.JSON(http.StatusOK, map[string]string{
|
||||
"status": "ok",
|
||||
"message": "Transcriber service is running",
|
||||
})
|
||||
})
|
||||
|
||||
se.Router.GET("/metrics", func(e *core.RequestEvent) error {
|
||||
promhttp.Handler().ServeHTTP(e.Response, e.Request)
|
||||
return nil
|
||||
})
|
||||
|
||||
return se.Next()
|
||||
})
|
||||
|
||||
// Добавляем эндпоинт для метрик Prometheus
|
||||
router.GET("/metrics", gin.WrapH(promhttp.Handler()))
|
||||
|
||||
// Создаем HTTP сервер
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
|
||||
Handler: router,
|
||||
}
|
||||
|
||||
// Запускаем HTTP сервер в отдельной горутине
|
||||
serveErr := make(chan error, 1)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
logger.Info("Starting HTTP server", "port", cfg.Server.Port)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
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)
|
||||
serveErr <- err
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -247,9 +248,13 @@ func main() {
|
||||
logger.Info("Workers: ConversionWorker, TranscribeWorker, CheckWorker")
|
||||
logger.Info("Press Ctrl+C to stop...")
|
||||
|
||||
// Ждем сигнал завершения
|
||||
<-sigChan
|
||||
logger.Info("Received shutdown signal, initiating graceful shutdown...")
|
||||
// Ждем сигнал завершения либо отказ сервера
|
||||
select {
|
||||
case <-sigChan:
|
||||
logger.Info("Received shutdown signal, initiating graceful shutdown...")
|
||||
case <-serveErr:
|
||||
logger.Error("HTTP server stopped unexpectedly, shutting down")
|
||||
}
|
||||
|
||||
if tgController != nil {
|
||||
logger.Info("Shutting down Telegram bot...")
|
||||
@@ -261,11 +266,16 @@ func main() {
|
||||
defer shutdownCancel()
|
||||
|
||||
// Останавливаем HTTP сервер
|
||||
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")
|
||||
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")
|
||||
}
|
||||
|
||||
// Отменяем контекст для остановки воркеров
|
||||
@@ -288,19 +298,3 @@ func main() {
|
||||
|
||||
logger.Info("Transcriber service stopped")
|
||||
}
|
||||
|
||||
func RunMigrations(db *sql.DB, logger *slog.Logger) error {
|
||||
if err := goose.SetDialect("sqlite3"); err != nil {
|
||||
return fmt.Errorf("failed to set goose dialect: %w", err)
|
||||
}
|
||||
|
||||
// Use the embedded filesystem for migrations
|
||||
goose.SetBaseFS(migrationsFS)
|
||||
|
||||
if err := goose.Up(db, "migrations"); err != nil {
|
||||
return fmt.Errorf("failed to run migrations: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("Migrations completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user