feat: scaffold MySQL services with lifecycle and readiness checks

This commit is contained in:
2026-09-20 23:12:47 +08:00
parent ff95692edb
commit affd02d5f7
28 changed files with 1153 additions and 63 deletions

View File

@@ -4,7 +4,7 @@
- `Config` / `ConfigFromEnv`:连接池参数和 `GO_OMNI_DB_*` 环境变量。
- `Open`:创建连接池、设置连接上限并在返回前执行 `PingContext`。
- `Health`:用于就绪检查。
- `Health`:用于就绪检查,等待连接和 Ping 均受 `PingTimeout` 限制。
- `InTx`:统一事务提交和回滚;工作函数返回错误或 panic 时回滚。
- `Close`:由嵌入的 `*sql.DB` 提供,应用退出时显式调用。
@@ -18,7 +18,13 @@ import (
)
cfg, err := database.ConfigFromEnv("GO_OMNI_DB_")
if err != nil {
return err
}
db, err := database.Open(context.Background(), cfg)
if err != nil {
return err
}
defer db.Close()
```
@@ -35,3 +41,18 @@ GO_OMNI_DB_PING_TIMEOUT=5s
```
`DSN` 必填,驱动默认是 `mysql`。数据库建库、迁移、表结构和具体 Repository 属于业务项目或可选模块,不会被核心包自动执行。
`MaxIdleConns=0` 会禁用空闲连接。初始化 Ping 失败会关闭已创建的连接池。
`go-omni new --database mysql` 会生成项目自己的 MySQL Repository、ServiceContext、
数据库就绪探针和 `schema.sql`。核心包不需要知道业务表结构。
真实数据库验收(需要本地 Docker):
```sh
python3 scripts/verifyDatabase.py --version v0.2.0
```
脚本仅操作本次创建的独立容器与数据库,验证 HTTP CRUD、服务重启持久化、事务提交与
错误/panic/取消回滚、连接池关闭、缺少表结构时启动失败,以及停库后的 readiness 503 和恢复后的 200。
结束时清理容器、匿名数据卷和临时项目。常规 `go test ./...` 不依赖 Docker。

View File

@@ -93,6 +93,9 @@ type DB struct {
}
func Open(ctx context.Context, cfg Config) (*DB, error) {
if ctx == nil {
ctx = context.Background()
}
if err := cfg.Validate(); err != nil {
return nil, err
}
@@ -103,9 +106,9 @@ func Open(ctx context.Context, cfg Config) (*DB, error) {
if cfg.MaxOpenConns > 0 {
db.SetMaxOpenConns(cfg.MaxOpenConns)
}
if cfg.MaxIdleConns > 0 {
db.SetMaxIdleConns(cfg.MaxIdleConns)
}
// database/sql uses zero to disable idle connections; do not leave its
// default pool size in place when the caller explicitly chooses zero.
db.SetMaxIdleConns(cfg.MaxIdleConns)
db.SetConnMaxLifetime(cfg.ConnMaxLifetime)
db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime)
checkCtx, cancel := context.WithTimeout(ctx, cfg.PingTimeout)
@@ -123,7 +126,12 @@ func (db *DB) Health(ctx context.Context) error {
if db == nil || db.DB == nil {
return errors.New("database is not initialized")
}
return db.PingContext(ctx)
if ctx == nil {
ctx = context.Background()
}
checkCtx, cancel := context.WithTimeout(ctx, db.config.PingTimeout)
defer cancel()
return db.PingContext(checkCtx)
}
// InTx commits only when work returns nil. Any work error or panic rolls back.

View File

@@ -0,0 +1,39 @@
package database
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"sync/atomic"
"testing"
)
var failedPingClosed atomic.Int32
var errPingUnavailable = errors.New("database unavailable")
type failedPingDriver struct{}
type failedPingConnection struct{}
func (failedPingDriver) Open(string) (driver.Conn, error) { return failedPingConnection{}, nil }
func (failedPingConnection) Prepare(string) (driver.Stmt, error) {
return nil, errors.New("not implemented")
}
func (failedPingConnection) Begin() (driver.Tx, error) { return nil, errors.New("not implemented") }
func (failedPingConnection) Close() error { failedPingClosed.Add(1); return nil }
func (failedPingConnection) Ping(context.Context) error { return errPingUnavailable }
func init() { sql.Register("goOmniFailPing", failedPingDriver{}) }
func TestOpenClosesPoolWhenStartupPingFails(t *testing.T) {
failedPingClosed.Store(0)
cfg := DefaultConfig()
cfg.Driver, cfg.DSN = "goOmniFailPing", "test"
pool, err := Open(context.Background(), cfg)
if pool != nil || !errors.Is(err, errPingUnavailable) {
t.Fatalf("unexpected startup result: db=%v err=%v", pool, err)
}
if closed := failedPingClosed.Load(); closed != 1 {
t.Fatalf("startup failure closed %d connections, want 1", closed)
}
}