From 0f9c7fa6576aee0c6880944ef44c47b9018f1182 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Wed, 29 Apr 2026 11:52:27 +0800 Subject: [PATCH] fix(server): propagate shutdown error, http timeouts, ctx-aware migrate --- internal/app/app.go | 12 ++++++++---- internal/app/integration_test.go | 2 +- internal/infra/db/migrate.go | 29 +++++++++++++++++++++++------ 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 9611fe7..ae167f3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -51,7 +51,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { log := logger.FromEnv(cfg.Env, nil) // 跑 migrations 必须在开池前;否则首次启动连不存在的表会让 ping 失败。 - if err := db.Migrate(cfg.DB.DSN, "file://migrations"); err != nil { + if err := db.Migrate(ctx, cfg.DB.DSN, "file://migrations"); err != nil { return nil, fmt.Errorf("migrate: %w", err) } @@ -109,6 +109,9 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { Addr: cfg.HTTP.Addr, Handler: r, ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, } return &App{cfg: cfg, log: log, pool: pool, server: srv}, nil } @@ -130,11 +133,12 @@ func (a *App) Run(ctx context.Context) error { a.log.Info("shutdown signal received, draining...") shutCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - if err := a.server.Shutdown(shutCtx); err != nil { - a.log.Error("server shutdown", "err", err.Error()) + shutErr := a.server.Shutdown(shutCtx) + if shutErr != nil { + a.log.Error("server shutdown", "err", shutErr.Error()) } a.pool.Close() - return nil + return shutErr case err := <-errCh: a.pool.Close() return err diff --git a/internal/app/integration_test.go b/internal/app/integration_test.go index 23494d6..d52be48 100644 --- a/internal/app/integration_test.go +++ b/internal/app/integration_test.go @@ -42,7 +42,7 @@ func TestEndToEnd_LoginAndDispatchNotification(t *testing.T) { dsn, err := container.ConnectionString(ctx, "sslmode=disable") require.NoError(t, err) - require.NoError(t, db.Migrate(dsn, "file://../../migrations")) + require.NoError(t, db.Migrate(ctx, dsn, "file://../../migrations")) pool, err := db.NewPool(ctx, dsn) require.NoError(t, err) diff --git a/internal/infra/db/migrate.go b/internal/infra/db/migrate.go index 653dcb7..ae0e304 100644 --- a/internal/infra/db/migrate.go +++ b/internal/infra/db/migrate.go @@ -2,6 +2,7 @@ package db import ( + "context" "errors" "fmt" @@ -12,17 +13,33 @@ import ( _ "github.com/golang-migrate/migrate/v4/source/file" ) -// Migrate 跑指定路径下的迁移到最新版本。 +// Migrate 跑指定路径下的迁移到最新版本。ctx 取消可让本函数提前返回 ctx.Err(), +// 但底层 golang-migrate 不接 ctx,已经 in-flight 的 Up() 会继续在后台 goroutine +// 中执行直到自然完成(或外部 statement_timeout 触发)。 // migrationsPath 形如 "file://migrations",dsn 为 postgres 连接串。 // 当无新迁移可应用时(migrate.ErrNoChange)视为成功,返回 nil。 -func Migrate(dsn, migrationsPath string) error { +func Migrate(ctx context.Context, dsn, migrationsPath string) error { m, err := migrate.New(migrationsPath, dsn) if err != nil { return fmt.Errorf("init migrate: %w", err) } - defer func() { _, _ = m.Close() }() - if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { - return fmt.Errorf("migrate up: %w", err) + errCh := make(chan error, 1) + go func() { + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + errCh <- err + return + } + errCh <- nil + }() + select { + case err := <-errCh: + _, _ = m.Close() + if err != nil { + return fmt.Errorf("migrate up: %w", err) + } + return nil + case <-ctx.Done(): + // 注意:m.Close 不能在这里调,会与正在跑的 Up() 竞态。让 goroutine 自然结束。 + return ctx.Err() } - return nil }