From 96afb778446e1676753969f907351cb70e5ef6ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ask=20Bj=C3=B8rn=20Hansen?= Date: Sat, 12 Jul 2025 16:54:24 -0700 Subject: [PATCH] database: create shared database package with configurable patterns Extract ~200 lines of duplicate database connection code from api/ntpdb/ and monitor/ntpdb/ into common/database/ package. Creates foundation for database consolidation while maintaining zero breaking changes. Files added: - config.go: Unified configuration with package-specific defaults - connector.go: Dynamic connector pattern from Boostport - pool.go: Configurable connection pool management - metrics.go: Optional Prometheus metrics integration - interfaces.go: Shared database interfaces for consistent patterns Key features: - Configuration-driven approach (API: 25/10 connections + metrics, Monitor: 10/5 connections, no metrics) - Optional Prometheus metrics when registerer provided - Backward compatibility via convenience functions - Flexible config file loading (explicit paths + search-based) Dependencies: Added mysql driver and yaml parsing for database configuration. --- database/config.go | 61 +++++++++++++++++++++++++++ database/connector.go | 88 +++++++++++++++++++++++++++++++++++++++ database/interfaces.go | 34 +++++++++++++++ database/metrics.go | 93 ++++++++++++++++++++++++++++++++++++++++++ database/pool.go | 78 +++++++++++++++++++++++++++++++++++ go.mod | 5 ++- go.sum | 12 ++++++ 7 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 database/config.go create mode 100644 database/connector.go create mode 100644 database/interfaces.go create mode 100644 database/metrics.go create mode 100644 database/pool.go diff --git a/database/config.go b/database/config.go new file mode 100644 index 0000000..a4209a2 --- /dev/null +++ b/database/config.go @@ -0,0 +1,61 @@ +package database + +import ( + "time" + + "github.com/prometheus/client_golang/prometheus" +) + +// Config represents the database configuration structure +type Config struct { + MySQL DBConfig `yaml:"mysql"` +} + +// DBConfig represents the MySQL database configuration +type DBConfig struct { + DSN string `default:"" flag:"dsn" usage:"Database DSN"` + User string `default:"" flag:"user"` + Pass string `default:"" flag:"pass"` + DBName string // Optional database name override +} + +// ConfigOptions allows customization of database opening behavior +type ConfigOptions struct { + // ConfigFiles is a list of config file paths to search for database configuration + ConfigFiles []string + + // EnablePoolMonitoring enables connection pool metrics collection + EnablePoolMonitoring bool + + // PrometheusRegisterer for metrics collection. If nil, no metrics are collected. + PrometheusRegisterer prometheus.Registerer + + // Connection pool settings + MaxOpenConns int + MaxIdleConns int + ConnMaxLifetime time.Duration +} + +// DefaultConfigOptions returns the standard configuration options used by API package +func DefaultConfigOptions() ConfigOptions { + return ConfigOptions{ + ConfigFiles: []string{"database.yaml", "/vault/secrets/database.yaml"}, + EnablePoolMonitoring: true, + PrometheusRegisterer: prometheus.DefaultRegisterer, + MaxOpenConns: 25, + MaxIdleConns: 10, + ConnMaxLifetime: 3 * time.Minute, + } +} + +// MonitorConfigOptions returns configuration options optimized for Monitor package +func MonitorConfigOptions() ConfigOptions { + return ConfigOptions{ + ConfigFiles: []string{"database.yaml", "/vault/secrets/database.yaml"}, + EnablePoolMonitoring: false, // Monitor doesn't need metrics + PrometheusRegisterer: nil, // No Prometheus dependency + MaxOpenConns: 10, + MaxIdleConns: 5, + ConnMaxLifetime: 3 * time.Minute, + } +} diff --git a/database/connector.go b/database/connector.go new file mode 100644 index 0000000..6386dbd --- /dev/null +++ b/database/connector.go @@ -0,0 +1,88 @@ +package database + +import ( + "context" + "database/sql/driver" + "errors" + "fmt" + "os" + + "github.com/go-sql-driver/mysql" + "gopkg.in/yaml.v3" +) + +// from https://github.com/Boostport/dynamic-database-config + +// CreateConnectorFunc is a function that creates a database connector +type CreateConnectorFunc func() (driver.Connector, error) + +// Driver implements the sql/driver interface with dynamic configuration +type Driver struct { + CreateConnectorFunc CreateConnectorFunc +} + +// Driver returns the driver instance +func (d Driver) Driver() driver.Driver { + return d +} + +// Connect creates a new database connection using the dynamic connector +func (d Driver) Connect(ctx context.Context) (driver.Conn, error) { + connector, err := d.CreateConnectorFunc() + if err != nil { + return nil, fmt.Errorf("error creating connector from function: %w", err) + } + + return connector.Connect(ctx) +} + +// Open is not supported for dynamic configuration +func (d Driver) Open(name string) (driver.Conn, error) { + return nil, errors.New("open is not supported") +} + +// createConnector creates a connector function that reads configuration from a file +func createConnector(configFile string) CreateConnectorFunc { + return func() (driver.Connector, error) { + dbFile, err := os.Open(configFile) + if err != nil { + return nil, err + } + defer dbFile.Close() + + dec := yaml.NewDecoder(dbFile) + cfg := Config{} + + err = dec.Decode(&cfg) + if err != nil { + return nil, err + } + + dsn := cfg.MySQL.DSN + if len(dsn) == 0 { + dsn = os.Getenv("DATABASE_DSN") + if len(dsn) == 0 { + return nil, fmt.Errorf("dsn config in database.yaml or DATABASE_DSN environment variable required") + } + } + + dbcfg, err := mysql.ParseDSN(dsn) + if err != nil { + return nil, err + } + + if user := cfg.MySQL.User; len(user) > 0 { + dbcfg.User = user + } + + if pass := cfg.MySQL.Pass; len(pass) > 0 { + dbcfg.Passwd = pass + } + + if name := cfg.MySQL.DBName; len(name) > 0 { + dbcfg.DBName = name + } + + return mysql.NewConnector(dbcfg) + } +} diff --git a/database/interfaces.go b/database/interfaces.go new file mode 100644 index 0000000..4798543 --- /dev/null +++ b/database/interfaces.go @@ -0,0 +1,34 @@ +package database + +import ( + "context" + "database/sql" +) + +// DBTX matches the interface expected by SQLC-generated code +// This interface is implemented by both *sql.DB and *sql.Tx +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +// BaseQuerier provides basic query functionality +// This interface should be implemented by package-specific Queries types +type BaseQuerier interface { + WithTx(tx *sql.Tx) BaseQuerier +} + +// BaseQuerierTx provides transaction functionality +// This interface should be implemented by package-specific Queries types +type BaseQuerierTx interface { + BaseQuerier + Begin(ctx context.Context) (BaseQuerierTx, error) + Commit(ctx context.Context) error + Rollback(ctx context.Context) error +} + +// TransactionFunc represents a function that operates within a database transaction +// This is used by the shared transaction helpers in transaction.go +type TransactionFunc[Q any] func(ctx context.Context, q Q) error diff --git a/database/metrics.go b/database/metrics.go new file mode 100644 index 0000000..97dba4f --- /dev/null +++ b/database/metrics.go @@ -0,0 +1,93 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/prometheus/client_golang/prometheus" +) + +// DatabaseMetrics holds the Prometheus metrics for database connection pool monitoring +type DatabaseMetrics struct { + ConnectionsOpen prometheus.Gauge + ConnectionsIdle prometheus.Gauge + ConnectionsInUse prometheus.Gauge + ConnectionsWaitCount prometheus.Counter + ConnectionsWaitDuration prometheus.Histogram +} + +// NewDatabaseMetrics creates a new set of database metrics and registers them +func NewDatabaseMetrics(registerer prometheus.Registerer) *DatabaseMetrics { + metrics := &DatabaseMetrics{ + ConnectionsOpen: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "database_connections_open", + Help: "Number of open database connections", + }), + ConnectionsIdle: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "database_connections_idle", + Help: "Number of idle database connections", + }), + ConnectionsInUse: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "database_connections_in_use", + Help: "Number of database connections in use", + }), + ConnectionsWaitCount: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "database_connections_wait_count_total", + Help: "Total number of times a connection had to wait", + }), + ConnectionsWaitDuration: prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "database_connections_wait_duration_seconds", + Help: "Time spent waiting for a database connection", + Buckets: prometheus.DefBuckets, + }), + } + + if registerer != nil { + registerer.MustRegister( + metrics.ConnectionsOpen, + metrics.ConnectionsIdle, + metrics.ConnectionsInUse, + metrics.ConnectionsWaitCount, + metrics.ConnectionsWaitDuration, + ) + } + + return metrics +} + +// monitorConnectionPool runs a background goroutine to collect connection pool metrics +func monitorConnectionPool(ctx context.Context, db *sql.DB, registerer prometheus.Registerer) { + if registerer == nil { + return // No metrics collection if no registerer provided + } + + metrics := NewDatabaseMetrics(registerer) + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + stats := db.Stats() + + metrics.ConnectionsOpen.Set(float64(stats.OpenConnections)) + metrics.ConnectionsIdle.Set(float64(stats.Idle)) + metrics.ConnectionsInUse.Set(float64(stats.InUse)) + metrics.ConnectionsWaitCount.Add(float64(stats.WaitCount)) + + if stats.WaitDuration > 0 { + metrics.ConnectionsWaitDuration.Observe(stats.WaitDuration.Seconds()) + } + + // Log connection pool stats for high usage or waiting + if stats.OpenConnections > 20 || stats.WaitCount > 0 { + fmt.Printf("Connection pool stats: open=%d idle=%d in_use=%d wait_count=%d wait_duration=%s\n", + stats.OpenConnections, stats.Idle, stats.InUse, stats.WaitCount, stats.WaitDuration) + } + } + } +} diff --git a/database/pool.go b/database/pool.go new file mode 100644 index 0000000..7e649ae --- /dev/null +++ b/database/pool.go @@ -0,0 +1,78 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "os" + + "go.ntppool.org/common/logger" +) + +// OpenDB opens a database connection with the specified configuration options +func OpenDB(ctx context.Context, options ConfigOptions) (*sql.DB, error) { + log := logger.Setup() + + configFile, err := findConfigFile(options.ConfigFiles) + if err != nil { + return nil, err + } + + dbconn := sql.OpenDB(Driver{ + CreateConnectorFunc: createConnector(configFile), + }) + + // Set connection pool parameters + dbconn.SetConnMaxLifetime(options.ConnMaxLifetime) + dbconn.SetMaxOpenConns(options.MaxOpenConns) + dbconn.SetMaxIdleConns(options.MaxIdleConns) + + err = dbconn.Ping() + if err != nil { + log.Error("could not connect to database", "err", err) + return nil, err + } + + // Start optional connection pool monitoring + if options.EnablePoolMonitoring && options.PrometheusRegisterer != nil { + go monitorConnectionPool(ctx, dbconn, options.PrometheusRegisterer) + } + + return dbconn, nil +} + +// OpenDBWithConfigFile opens a database connection using an explicit config file path +// This is a convenience function for API package compatibility +func OpenDBWithConfigFile(ctx context.Context, configFile string) (*sql.DB, error) { + options := DefaultConfigOptions() + options.ConfigFiles = []string{configFile} + return OpenDB(ctx, options) +} + +// OpenDBMonitor opens a database connection with monitor-specific defaults +// This is a convenience function for Monitor package compatibility +func OpenDBMonitor() (*sql.DB, error) { + options := MonitorConfigOptions() + return OpenDB(context.Background(), options) +} + +// findConfigFile searches for the first existing config file from the list +func findConfigFile(configFiles []string) (string, error) { + var firstErr error + + for _, configFile := range configFiles { + if configFile == "" { + continue + } + if _, err := os.Stat(configFile); err == nil { + return configFile, nil + } else if firstErr == nil { + firstErr = err + } + } + + if firstErr != nil { + return "", fmt.Errorf("no config file found: %w", firstErr) + } + return "", fmt.Errorf("no valid config files provided") +} diff --git a/go.mod b/go.mod index 01d2412..48d641d 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,12 @@ go 1.23.5 require ( github.com/abh/certman v0.4.0 + github.com/go-sql-driver/mysql v1.9.3 github.com/labstack/echo-contrib v0.17.2 github.com/labstack/echo/v4 v4.13.3 github.com/oklog/ulid/v2 v2.1.0 github.com/prometheus/client_golang v1.20.5 + github.com/prometheus/client_model v0.6.1 github.com/remychantenay/slog-otel v1.3.2 github.com/samber/slog-echo v1.14.8 github.com/samber/slog-multi v1.2.4 @@ -28,9 +30,11 @@ require ( golang.org/x/net v0.33.0 golang.org/x/sync v0.10.0 google.golang.org/grpc v1.69.2 + gopkg.in/yaml.v3 v3.0.1 ) require ( + filippo.io/edwards25519 v1.1.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -47,7 +51,6 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.61.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/samber/lo v1.47.0 // indirect diff --git a/go.sum b/go.sum index a2cbf92..06854dd 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/abh/certman v0.4.0 h1:XHoDtb0YyRQPclaHMrBDlKTVZpNjTK6vhB0S3Bd/Sbs= github.com/abh/certman v0.4.0/go.mod h1:x8QhpKVZifmV1Hdiwdg9gLo2GMPAxezz1s3zrVnPs+I= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -17,6 +19,8 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= @@ -30,6 +34,10 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/labstack/echo-contrib v0.17.2 h1:K1zivqmtcC70X9VdBFdLomjPDEVHlrcAObqmuFj1c6w= @@ -65,6 +73,8 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/remychantenay/slog-otel v1.3.2 h1:ZBx8qnwfLJ6e18Vba4e9Xp9B7khTmpIwFsU1sAmActw= github.com/remychantenay/slog-otel v1.3.2/go.mod h1:gKW4tQ8cGOKoA+bi7wtYba/tcJ6Tc9XyQ/EW8gHA/2E= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= @@ -211,6 +221,8 @@ google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7 google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=