feat(dbmanager): report pool max size and total connections opened

Add MaxOpenConnections to provider and connection stats, exposed as the
max state of dbmanager_connection_pool_size. Count physical dials through a
counting driver connector and export dbmanager_connections_opened_total;
existing_db pools derive an approximate count from sql.DBStats.
This commit is contained in:
Hein
2026-09-30 15:46:41 +02:00
parent d3a99550d9
commit 178d40587d
11 changed files with 191 additions and 51 deletions
+51
View File
@@ -0,0 +1,51 @@
package providers
import (
"context"
"database/sql"
"database/sql/driver"
"sync/atomic"
)
// countingConnector wraps a driver.Connector and counts every physical
// connection it successfully dials. sql.DBStats has no such field, so this is
// the only way to report the total number of connections ever opened.
type countingConnector struct {
driver.Connector
opened *atomic.Int64
}
func (c *countingConnector) Connect(ctx context.Context) (driver.Conn, error) {
conn, err := c.Connector.Connect(ctx)
if err == nil {
c.opened.Add(1)
}
return conn, err
}
// dsnConnector adapts a plain driver.Driver to driver.Connector.
type dsnConnector struct {
dsn string
drv driver.Driver
}
func (c dsnConnector) Connect(context.Context) (driver.Conn, error) { return c.drv.Open(c.dsn) }
func (c dsnConnector) Driver() driver.Driver { return c.drv }
// openCounted is sql.Open with every dialled connection counted in opened.
func openCounted(driverName, dsn string, opened *atomic.Int64) (*sql.DB, error) {
probe, err := sql.Open(driverName, dsn)
if err != nil {
return nil, err
}
drv := probe.Driver()
probe.Close() //nolint:gosec // G104: probe handle never dialled
var connector driver.Connector = dsnConnector{dsn: dsn, drv: drv}
if dc, ok := drv.(driver.DriverContext); ok {
if connector, err = dc.OpenConnector(dsn); err != nil {
return nil, err
}
}
return sql.OpenDB(&countingConnector{Connector: connector, opened: opened}), nil
}
+6
View File
@@ -112,6 +112,12 @@ func (p *ExistingDBProvider) Stats() *ConnectionStats {
if p.db != nil {
dbStats := p.db.Stats()
stats.OpenConnections = dbStats.OpenConnections
stats.MaxOpenConnections = dbStats.MaxOpenConnections
// The pool was opened outside dbmanager so dials cannot be counted.
// Open plus every connection database/sql retired for idle or
// lifetime limits is a close lower bound (it misses connections
// dropped as broken).
stats.TotalOpened = int64(dbStats.OpenConnections) + dbStats.MaxIdleClosed + dbStats.MaxIdleTimeClosed + dbStats.MaxLifetimeClosed
stats.InUse = dbStats.InUse
stats.Idle = dbStats.Idle
stats.WaitCount = dbStats.WaitCount
@@ -3,9 +3,11 @@ package providers
import (
"context"
"database/sql"
"sync/atomic"
"testing"
"time"
_ "github.com/glebarez/sqlite"
_ "github.com/mattn/go-sqlite3"
)
@@ -159,6 +161,10 @@ func TestExistingDBProvider_Stats(t *testing.T) {
t.Errorf("Expected stats.Type to be 'sql', got '%s'", stats.Type)
}
if stats.MaxOpenConnections != 10 {
t.Errorf("Expected stats.MaxOpenConnections to be 10, got %d", stats.MaxOpenConnections)
}
if !stats.Connected {
t.Error("Expected stats.Connected to be true")
}
@@ -192,3 +198,35 @@ func TestExistingDBProvider_Close_NilDB(t *testing.T) {
t.Errorf("Expected Close to succeed with nil database, got error: %v", err)
}
}
func TestOpenCountedCountsDials(t *testing.T) {
var opened atomic.Int64
db, err := openCounted("sqlite", ":memory:", &opened)
if err != nil {
t.Fatal(err)
}
defer db.Close()
db.SetMaxOpenConns(1)
if err := db.PingContext(context.Background()); err != nil {
t.Fatal(err)
}
if got := opened.Load(); got != 1 {
t.Fatalf("opened = %d after first ping, want 1", got)
}
// Reusing the pooled connection must not count as a new dial.
if err := db.PingContext(context.Background()); err != nil {
t.Fatal(err)
}
if got := opened.Load(); got != 1 {
t.Fatalf("opened = %d after reuse, want 1", got)
}
// Dropping idle connections forces a fresh dial.
db.SetMaxIdleConns(0)
if err := db.PingContext(context.Background()); err != nil {
t.Fatal(err)
}
if got := opened.Load(); got != 2 {
t.Fatalf("opened = %d after redial, want 2", got)
}
}
+15 -11
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"fmt"
"sync/atomic"
"time"
_ "github.com/microsoft/go-mssqldb" // MSSQL driver
@@ -16,6 +17,7 @@ import (
type MSSQLProvider struct {
db *sql.DB
config ConnectionConfig
opened atomic.Int64
}
// NewMSSQLProvider creates a new MSSQL provider
@@ -52,7 +54,7 @@ func (p *MSSQLProvider) Connect(ctx context.Context, cfg ConnectionConfig) error
}
// Open database connection
db, err = sql.Open("sqlserver", dsn)
db, err = openCounted("sqlserver", dsn, &p.opened)
if err != nil {
lastErr = err
if cfg.GetEnableLogging() {
@@ -169,15 +171,17 @@ func (p *MSSQLProvider) Stats() *ConnectionStats {
stats := p.db.Stats()
return &ConnectionStats{
Name: p.config.GetName(),
Type: "mssql",
Connected: true,
OpenConnections: stats.OpenConnections,
InUse: stats.InUse,
Idle: stats.Idle,
WaitCount: stats.WaitCount,
WaitDuration: stats.WaitDuration,
MaxIdleClosed: stats.MaxIdleClosed,
MaxLifetimeClosed: stats.MaxLifetimeClosed,
Name: p.config.GetName(),
Type: "mssql",
Connected: true,
OpenConnections: stats.OpenConnections,
MaxOpenConnections: stats.MaxOpenConnections,
TotalOpened: p.opened.Load(),
InUse: stats.InUse,
Idle: stats.Idle,
WaitCount: stats.WaitCount,
WaitDuration: stats.WaitDuration,
MaxIdleClosed: stats.MaxIdleClosed,
MaxLifetimeClosed: stats.MaxLifetimeClosed,
}
}
+15 -11
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"math"
"sync"
"sync/atomic"
"time"
"go.mongodb.org/mongo-driver/mongo"
@@ -21,6 +22,7 @@ type PostgresProvider struct {
config ConnectionConfig
listener *PostgresListener
mu sync.Mutex
opened atomic.Int64
}
// NewPostgresProvider creates a new PostgreSQL provider
@@ -38,7 +40,7 @@ func (p *PostgresProvider) Connect(ctx context.Context, cfg ConnectionConfig) er
// The connector and *sql.DB are created once; the pool is never closed to
// recover from errors (see Refresh).
connector := newPGConnector(connCfg)
db := sql.OpenDB(connector)
db := sql.OpenDB(&countingConnector{Connector: connector, opened: &p.opened})
// Connect with retry logic
var lastErr error
@@ -201,16 +203,18 @@ func (p *PostgresProvider) Stats() *ConnectionStats {
stats := p.db.Stats()
return &ConnectionStats{
Name: p.config.GetName(),
Type: "postgres",
Connected: true,
OpenConnections: stats.OpenConnections,
InUse: stats.InUse,
Idle: stats.Idle,
WaitCount: stats.WaitCount,
WaitDuration: stats.WaitDuration,
MaxIdleClosed: stats.MaxIdleClosed,
MaxLifetimeClosed: stats.MaxLifetimeClosed,
Name: p.config.GetName(),
Type: "postgres",
Connected: true,
OpenConnections: stats.OpenConnections,
MaxOpenConnections: stats.MaxOpenConnections,
TotalOpened: p.opened.Load(),
InUse: stats.InUse,
Idle: stats.Idle,
WaitCount: stats.WaitCount,
WaitDuration: stats.WaitDuration,
MaxIdleClosed: stats.MaxIdleClosed,
MaxLifetimeClosed: stats.MaxLifetimeClosed,
}
}
+9 -7
View File
@@ -27,13 +27,15 @@ type ConnectionStats struct {
HealthCheckStatus string
// SQL connection pool stats
OpenConnections int
InUse int
Idle int
WaitCount int64
WaitDuration time.Duration
MaxIdleClosed int64
MaxLifetimeClosed int64
OpenConnections int
MaxOpenConnections int
TotalOpened int64 // physical connections ever dialled (0 when unknown)
InUse int
Idle int
WaitCount int64
WaitDuration time.Duration
MaxIdleClosed int64
MaxLifetimeClosed int64
}
// ConnectionConfig is a minimal interface for configuration
+15 -11
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
_ "github.com/glebarez/sqlite" // Pure Go SQLite driver
@@ -19,6 +20,7 @@ type SQLiteProvider struct {
db *sql.DB
dbMu sync.RWMutex
config ConnectionConfig
opened atomic.Int64
}
// NewSQLiteProvider creates a new SQLite provider
@@ -51,7 +53,7 @@ func (p *SQLiteProvider) Connect(ctx context.Context, cfg ConnectionConfig) erro
}
// Open database connection
db, err := sql.Open("sqlite", dsn)
db, err := openCounted("sqlite", dsn, &p.opened)
if err != nil {
return fmt.Errorf("failed to open SQLite connection: %w", err)
}
@@ -178,15 +180,17 @@ func (p *SQLiteProvider) Stats() *ConnectionStats {
stats := p.db.Stats()
return &ConnectionStats{
Name: p.config.GetName(),
Type: "sqlite",
Connected: true,
OpenConnections: stats.OpenConnections,
InUse: stats.InUse,
Idle: stats.Idle,
WaitCount: stats.WaitCount,
WaitDuration: stats.WaitDuration,
MaxIdleClosed: stats.MaxIdleClosed,
MaxLifetimeClosed: stats.MaxLifetimeClosed,
Name: p.config.GetName(),
Type: "sqlite",
Connected: true,
OpenConnections: stats.OpenConnections,
MaxOpenConnections: stats.MaxOpenConnections,
TotalOpened: p.opened.Load(),
InUse: stats.InUse,
Idle: stats.Idle,
WaitCount: stats.WaitCount,
WaitDuration: stats.WaitDuration,
MaxIdleClosed: stats.MaxIdleClosed,
MaxLifetimeClosed: stats.MaxLifetimeClosed,
}
}