feat(dbmanager): Database connection Manager

This commit is contained in:
Hein
2026-01-02 16:19:33 +02:00
parent 443a672fcb
commit 6f05f15ff6
23 changed files with 4436 additions and 26 deletions

531
pkg/dbmanager/README.md Normal file
View File

@@ -0,0 +1,531 @@
# Database Connection Manager (dbmanager)
A comprehensive database connection manager for Go that provides centralized management of multiple named database connections with support for PostgreSQL, SQLite, MSSQL, and MongoDB.
## Features
- **Multiple Named Connections**: Manage multiple database connections with names like `primary`, `analytics`, `cache-db`
- **Multi-Database Support**: PostgreSQL, SQLite, Microsoft SQL Server, and MongoDB
- **Multi-ORM Access**: Each SQL connection provides access through:
- **Bun ORM** - Modern, lightweight ORM
- **GORM** - Popular Go ORM
- **Native** - Standard library `*sql.DB`
- All three share the same underlying connection pool
- **Configuration-Driven**: YAML configuration with Viper integration
- **Production-Ready Features**:
- Automatic health checks and reconnection
- Prometheus metrics
- Connection pooling with configurable limits
- Retry logic with exponential backoff
- Graceful shutdown
- OpenTelemetry tracing support
## Installation
```bash
go get github.com/bitechdev/ResolveSpec/pkg/dbmanager
```
## Quick Start
### 1. Configuration
Create a configuration file (e.g., `config.yaml`):
```yaml
dbmanager:
default_connection: "primary"
# Global connection pool defaults
max_open_conns: 25
max_idle_conns: 5
conn_max_lifetime: 30m
conn_max_idle_time: 5m
# Retry configuration
retry_attempts: 3
retry_delay: 1s
retry_max_delay: 10s
# Health checks
health_check_interval: 30s
enable_auto_reconnect: true
connections:
# Primary PostgreSQL connection
primary:
type: postgres
host: localhost
port: 5432
user: myuser
password: mypassword
database: myapp
sslmode: disable
default_orm: bun
enable_metrics: true
enable_tracing: true
enable_logging: true
# Read replica for analytics
analytics:
type: postgres
dsn: "postgres://readonly:pass@analytics:5432/analytics"
default_orm: bun
enable_metrics: true
# SQLite cache
cache-db:
type: sqlite
filepath: /var/lib/app/cache.db
max_open_conns: 1
# MongoDB for documents
documents:
type: mongodb
host: localhost
port: 27017
database: documents
user: mongouser
password: mongopass
auth_source: admin
enable_metrics: true
```
### 2. Initialize Manager
```go
package main
import (
"context"
"log"
"github.com/bitechdev/ResolveSpec/pkg/config"
"github.com/bitechdev/ResolveSpec/pkg/dbmanager"
)
func main() {
// Load configuration
cfgMgr := config.NewManager()
if err := cfgMgr.Load(); err != nil {
log.Fatal(err)
}
cfg, _ := cfgMgr.GetConfig()
// Create database manager
mgr, err := dbmanager.NewManager(cfg.DBManager)
if err != nil {
log.Fatal(err)
}
defer mgr.Close()
// Connect all databases
ctx := context.Background()
if err := mgr.Connect(ctx); err != nil {
log.Fatal(err)
}
// Your application code here...
}
```
### 3. Use Database Connections
#### Get Default Database
```go
// Get the default database (as configured common.Database interface)
db, err := mgr.GetDefaultDatabase()
if err != nil {
log.Fatal(err)
}
// Use it with any query
var users []User
err = db.NewSelect().
Model(&users).
Where("active = ?", true).
Scan(ctx, &users)
```
#### Get Named Connection with Specific ORM
```go
// Get primary connection
primary, err := mgr.Get("primary")
if err != nil {
log.Fatal(err)
}
// Use with Bun
bunDB, err := primary.Bun()
if err != nil {
log.Fatal(err)
}
err = bunDB.NewSelect().Model(&users).Scan(ctx)
// Use with GORM (same underlying connection!)
gormDB, err := primary.GORM()
if err != nil {
log.Fatal(err)
}
gormDB.Where("active = ?", true).Find(&users)
// Use native *sql.DB
nativeDB, err := primary.Native()
if err != nil {
log.Fatal(err)
}
rows, err := nativeDB.QueryContext(ctx, "SELECT * FROM users WHERE active = $1", true)
```
#### Use MongoDB
```go
// Get MongoDB connection
docs, err := mgr.Get("documents")
if err != nil {
log.Fatal(err)
}
mongoClient, err := docs.MongoDB()
if err != nil {
log.Fatal(err)
}
collection := mongoClient.Database("documents").Collection("articles")
// Use MongoDB driver...
```
#### Change Default Database
```go
// Switch to analytics database as default
err := mgr.SetDefaultDatabase("analytics")
if err != nil {
log.Fatal(err)
}
// Now GetDefaultDatabase() returns the analytics connection
db, _ := mgr.GetDefaultDatabase()
```
## Configuration Reference
### Manager Configuration
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `default_connection` | string | "" | Name of the default connection |
| `connections` | map | {} | Map of connection name to ConnectionConfig |
| `max_open_conns` | int | 25 | Global default for max open connections |
| `max_idle_conns` | int | 5 | Global default for max idle connections |
| `conn_max_lifetime` | duration | 30m | Global default for connection max lifetime |
| `conn_max_idle_time` | duration | 5m | Global default for connection max idle time |
| `retry_attempts` | int | 3 | Number of connection retry attempts |
| `retry_delay` | duration | 1s | Initial retry delay |
| `retry_max_delay` | duration | 10s | Maximum retry delay |
| `health_check_interval` | duration | 30s | Interval between health checks |
| `enable_auto_reconnect` | bool | true | Auto-reconnect on health check failure |
### Connection Configuration
| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Unique connection name |
| `type` | string | Database type: `postgres`, `sqlite`, `mssql`, `mongodb` |
| `dsn` | string | Complete connection string (overrides individual params) |
| `host` | string | Database host |
| `port` | int | Database port |
| `user` | string | Username |
| `password` | string | Password |
| `database` | string | Database name |
| `sslmode` | string | SSL mode (postgres/mssql): `disable`, `require`, etc. |
| `schema` | string | Default schema (postgres/mssql) |
| `filepath` | string | File path (sqlite only) |
| `auth_source` | string | Auth source (mongodb) |
| `replica_set` | string | Replica set name (mongodb) |
| `read_preference` | string | Read preference (mongodb): `primary`, `secondary`, etc. |
| `max_open_conns` | int | Override global max open connections |
| `max_idle_conns` | int | Override global max idle connections |
| `conn_max_lifetime` | duration | Override global connection max lifetime |
| `conn_max_idle_time` | duration | Override global connection max idle time |
| `connect_timeout` | duration | Connection timeout (default: 10s) |
| `query_timeout` | duration | Query timeout (default: 30s) |
| `enable_tracing` | bool | Enable OpenTelemetry tracing |
| `enable_metrics` | bool | Enable Prometheus metrics |
| `enable_logging` | bool | Enable connection logging |
| `default_orm` | string | Default ORM for Database(): `bun`, `gorm`, `native` |
| `tags` | map[string]string | Custom tags for filtering/organization |
## Advanced Usage
### Health Checks
```go
// Manual health check
if err := mgr.HealthCheck(ctx); err != nil {
log.Printf("Health check failed: %v", err)
}
// Per-connection health check
primary, _ := mgr.Get("primary")
if err := primary.HealthCheck(ctx); err != nil {
log.Printf("Primary connection unhealthy: %v", err)
// Manual reconnect
if err := primary.Reconnect(ctx); err != nil {
log.Printf("Reconnection failed: %v", err)
}
}
```
### Connection Statistics
```go
// Get overall statistics
stats := mgr.Stats()
fmt.Printf("Total connections: %d\n", stats.TotalConnections)
fmt.Printf("Healthy: %d, Unhealthy: %d\n", stats.HealthyCount, stats.UnhealthyCount)
// Per-connection stats
for name, connStats := range stats.ConnectionStats {
fmt.Printf("%s: %d open, %d in use, %d idle\n",
name,
connStats.OpenConnections,
connStats.InUse,
connStats.Idle)
}
// Individual connection stats
primary, _ := mgr.Get("primary")
stats := primary.Stats()
fmt.Printf("Wait count: %d, Wait duration: %v\n",
stats.WaitCount,
stats.WaitDuration)
```
### Prometheus Metrics
The package automatically exports Prometheus metrics:
- `dbmanager_connections_total` - Total configured connections by type
- `dbmanager_connection_status` - Connection health status (1=healthy, 0=unhealthy)
- `dbmanager_connection_pool_size` - Connection pool statistics by state
- `dbmanager_connection_wait_count` - Times connections waited for availability
- `dbmanager_connection_wait_duration_seconds` - Total wait duration
- `dbmanager_health_check_duration_seconds` - Health check execution time
- `dbmanager_reconnect_attempts_total` - Reconnection attempts and results
- `dbmanager_connection_lifetime_closed_total` - Connections closed due to max lifetime
- `dbmanager_connection_idle_closed_total` - Connections closed due to max idle time
Metrics are automatically updated during health checks. To manually publish metrics:
```go
if mgr, ok := mgr.(*connectionManager); ok {
mgr.PublishMetrics()
}
```
## Architecture
### Single Connection Pool, Multiple ORMs
A key design principle is that Bun, GORM, and Native all wrap the **same underlying `*sql.DB`** connection pool:
```
┌─────────────────────────────────────┐
│ SQL Connection │
├─────────────────────────────────────┤
│ ┌─────────┐ ┌──────┐ ┌────────┐ │
│ │ Bun │ │ GORM │ │ Native │ │
│ └────┬────┘ └───┬──┘ └───┬────┘ │
│ │ │ │ │
│ └───────────┴─────────┘ │
│ *sql.DB │
│ (single pool) │
└─────────────────────────────────────┘
```
**Benefits:**
- No connection duplication
- Consistent pool limits across all ORMs
- Unified connection statistics
- Lower resource usage
### Provider Pattern
Each database type has a dedicated provider:
- **PostgresProvider** - Uses `pgx` driver
- **SQLiteProvider** - Uses `glebarez/sqlite` (pure Go)
- **MSSQLProvider** - Uses `go-mssqldb`
- **MongoProvider** - Uses official `mongo-driver`
Providers handle:
- Connection establishment with retry logic
- Health checking
- Connection statistics
- Connection cleanup
## Best Practices
1. **Use Named Connections**: Be explicit about which database you're accessing
```go
primary, _ := mgr.Get("primary") // Good
db, _ := mgr.GetDefaultDatabase() // Risky if default changes
```
2. **Configure Connection Pools**: Tune based on your workload
```yaml
connections:
primary:
max_open_conns: 100 # High traffic API
max_idle_conns: 25
analytics:
max_open_conns: 10 # Background analytics
max_idle_conns: 2
```
3. **Enable Health Checks**: Catch connection issues early
```yaml
health_check_interval: 30s
enable_auto_reconnect: true
```
4. **Use Appropriate ORM**: Choose based on your needs
- **Bun**: Modern, fast, type-safe - recommended for new code
- **GORM**: Mature, feature-rich - good for existing GORM code
- **Native**: Maximum control - use for performance-critical queries
5. **Monitor Metrics**: Watch connection pool utilization
- If `wait_count` is high, increase `max_open_conns`
- If `idle` is always high, decrease `max_idle_conns`
## Troubleshooting
### Connection Failures
If connections fail to establish:
1. Check configuration:
```bash
# Test connection manually
psql -h localhost -U myuser -d myapp
```
2. Enable logging:
```yaml
connections:
primary:
enable_logging: true
```
3. Check retry attempts:
```yaml
retry_attempts: 5 # Increase retries
retry_max_delay: 30s
```
### Pool Exhaustion
If you see "too many connections" errors:
1. Increase pool size:
```yaml
max_open_conns: 50 # Increase from default 25
```
2. Reduce connection lifetime:
```yaml
conn_max_lifetime: 15m # Recycle faster
```
3. Monitor wait stats:
```go
stats := primary.Stats()
if stats.WaitCount > 1000 {
log.Warn("High connection wait count")
}
```
### MongoDB vs SQL Confusion
MongoDB connections don't support SQL ORMs:
```go
docs, _ := mgr.Get("documents")
// ✓ Correct
mongoClient, _ := docs.MongoDB()
// ✗ Error: ErrNotSQLDatabase
bunDB, err := docs.Bun() // Won't work!
```
SQL connections don't support MongoDB:
```go
primary, _ := mgr.Get("primary")
// ✓ Correct
bunDB, _ := primary.Bun()
// ✗ Error: ErrNotMongoDB
mongoClient, err := primary.MongoDB() // Won't work!
```
## Migration Guide
### From Raw `database/sql`
Before:
```go
db, err := sql.Open("postgres", dsn)
defer db.Close()
rows, err := db.Query("SELECT * FROM users")
```
After:
```go
mgr, _ := dbmanager.NewManager(cfg.DBManager)
mgr.Connect(ctx)
defer mgr.Close()
primary, _ := mgr.Get("primary")
nativeDB, _ := primary.Native()
rows, err := nativeDB.Query("SELECT * FROM users")
```
### From Direct Bun/GORM
Before:
```go
sqldb, _ := sql.Open("pgx", dsn)
bunDB := bun.NewDB(sqldb, pgdialect.New())
var users []User
bunDB.NewSelect().Model(&users).Scan(ctx)
```
After:
```go
mgr, _ := dbmanager.NewManager(cfg.DBManager)
mgr.Connect(ctx)
primary, _ := mgr.Get("primary")
bunDB, _ := primary.Bun()
var users []User
bunDB.NewSelect().Model(&users).Scan(ctx)
```
## License
Same as the parent project.
## Contributing
Contributions are welcome! Please submit issues and pull requests to the main repository.

448
pkg/dbmanager/config.go Normal file
View File

@@ -0,0 +1,448 @@
package dbmanager
import (
"fmt"
"time"
"github.com/bitechdev/ResolveSpec/pkg/config"
)
// DatabaseType represents the type of database
type DatabaseType string
const (
// DatabaseTypePostgreSQL represents PostgreSQL database
DatabaseTypePostgreSQL DatabaseType = "postgres"
// DatabaseTypeSQLite represents SQLite database
DatabaseTypeSQLite DatabaseType = "sqlite"
// DatabaseTypeMSSQL represents Microsoft SQL Server database
DatabaseTypeMSSQL DatabaseType = "mssql"
// DatabaseTypeMongoDB represents MongoDB database
DatabaseTypeMongoDB DatabaseType = "mongodb"
)
// ORMType represents the ORM to use for database operations
type ORMType string
const (
// ORMTypeBun represents Bun ORM
ORMTypeBun ORMType = "bun"
// ORMTypeGORM represents GORM
ORMTypeGORM ORMType = "gorm"
// ORMTypeNative represents native database/sql
ORMTypeNative ORMType = "native"
)
// ManagerConfig contains configuration for the database connection manager
type ManagerConfig struct {
// DefaultConnection is the name of the default connection to use
DefaultConnection string `mapstructure:"default_connection"`
// Connections is a map of connection name to connection configuration
Connections map[string]ConnectionConfig `mapstructure:"connections"`
// Global connection pool defaults
MaxOpenConns int `mapstructure:"max_open_conns"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
ConnMaxLifetime time.Duration `mapstructure:"conn_max_lifetime"`
ConnMaxIdleTime time.Duration `mapstructure:"conn_max_idle_time"`
// Retry policy
RetryAttempts int `mapstructure:"retry_attempts"`
RetryDelay time.Duration `mapstructure:"retry_delay"`
RetryMaxDelay time.Duration `mapstructure:"retry_max_delay"`
// Health checks
HealthCheckInterval time.Duration `mapstructure:"health_check_interval"`
EnableAutoReconnect bool `mapstructure:"enable_auto_reconnect"`
}
// ConnectionConfig defines configuration for a single database connection
type ConnectionConfig struct {
// Name is the unique name of this connection
Name string `mapstructure:"name"`
// Type is the database type (postgres, sqlite, mssql, mongodb)
Type DatabaseType `mapstructure:"type"`
// DSN is the complete Data Source Name / connection string
// If provided, this takes precedence over individual connection parameters
DSN string `mapstructure:"dsn"`
// Connection parameters (used if DSN is not provided)
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
Database string `mapstructure:"database"`
// PostgreSQL/MSSQL specific
SSLMode string `mapstructure:"sslmode"` // disable, require, verify-ca, verify-full
Schema string `mapstructure:"schema"` // Default schema
// SQLite specific
FilePath string `mapstructure:"filepath"`
// MongoDB specific
AuthSource string `mapstructure:"auth_source"`
ReplicaSet string `mapstructure:"replica_set"`
ReadPreference string `mapstructure:"read_preference"` // primary, secondary, etc.
// Connection pool settings (overrides global defaults)
MaxOpenConns *int `mapstructure:"max_open_conns"`
MaxIdleConns *int `mapstructure:"max_idle_conns"`
ConnMaxLifetime *time.Duration `mapstructure:"conn_max_lifetime"`
ConnMaxIdleTime *time.Duration `mapstructure:"conn_max_idle_time"`
// Timeouts
ConnectTimeout time.Duration `mapstructure:"connect_timeout"`
QueryTimeout time.Duration `mapstructure:"query_timeout"`
// Features
EnableTracing bool `mapstructure:"enable_tracing"`
EnableMetrics bool `mapstructure:"enable_metrics"`
EnableLogging bool `mapstructure:"enable_logging"`
// DefaultORM specifies which ORM to use for the Database() method
// Options: "bun", "gorm", "native"
DefaultORM string `mapstructure:"default_orm"`
// Tags for organization and filtering
Tags map[string]string `mapstructure:"tags"`
}
// DefaultManagerConfig returns a ManagerConfig with sensible defaults
func DefaultManagerConfig() ManagerConfig {
return ManagerConfig{
DefaultConnection: "",
Connections: make(map[string]ConnectionConfig),
MaxOpenConns: 25,
MaxIdleConns: 5,
ConnMaxLifetime: 30 * time.Minute,
ConnMaxIdleTime: 5 * time.Minute,
RetryAttempts: 3,
RetryDelay: 1 * time.Second,
RetryMaxDelay: 10 * time.Second,
HealthCheckInterval: 30 * time.Second,
EnableAutoReconnect: true,
}
}
// ApplyDefaults applies default values to the manager configuration
func (c *ManagerConfig) ApplyDefaults() {
defaults := DefaultManagerConfig()
if c.MaxOpenConns == 0 {
c.MaxOpenConns = defaults.MaxOpenConns
}
if c.MaxIdleConns == 0 {
c.MaxIdleConns = defaults.MaxIdleConns
}
if c.ConnMaxLifetime == 0 {
c.ConnMaxLifetime = defaults.ConnMaxLifetime
}
if c.ConnMaxIdleTime == 0 {
c.ConnMaxIdleTime = defaults.ConnMaxIdleTime
}
if c.RetryAttempts == 0 {
c.RetryAttempts = defaults.RetryAttempts
}
if c.RetryDelay == 0 {
c.RetryDelay = defaults.RetryDelay
}
if c.RetryMaxDelay == 0 {
c.RetryMaxDelay = defaults.RetryMaxDelay
}
if c.HealthCheckInterval == 0 {
c.HealthCheckInterval = defaults.HealthCheckInterval
}
}
// Validate validates the manager configuration
func (c *ManagerConfig) Validate() error {
if len(c.Connections) == 0 {
return NewConfigurationError("connections", fmt.Errorf("at least one connection must be configured"))
}
if c.DefaultConnection != "" {
if _, ok := c.Connections[c.DefaultConnection]; !ok {
return NewConfigurationError("default_connection", fmt.Errorf("default connection '%s' not found in connections", c.DefaultConnection))
}
}
// Validate each connection
for name := range c.Connections {
conn := c.Connections[name]
if err := conn.Validate(); err != nil {
return fmt.Errorf("connection '%s': %w", name, err)
}
}
return nil
}
// ApplyDefaults applies default values and global settings to the connection configuration
func (cc *ConnectionConfig) ApplyDefaults(global *ManagerConfig) {
// Set name if not already set
if cc.Name == "" {
cc.Name = "unnamed"
}
// Apply global pool settings if not overridden
if cc.MaxOpenConns == nil && global != nil {
maxOpen := global.MaxOpenConns
cc.MaxOpenConns = &maxOpen
}
if cc.MaxIdleConns == nil && global != nil {
maxIdle := global.MaxIdleConns
cc.MaxIdleConns = &maxIdle
}
if cc.ConnMaxLifetime == nil && global != nil {
lifetime := global.ConnMaxLifetime
cc.ConnMaxLifetime = &lifetime
}
if cc.ConnMaxIdleTime == nil && global != nil {
idleTime := global.ConnMaxIdleTime
cc.ConnMaxIdleTime = &idleTime
}
// Default timeouts
if cc.ConnectTimeout == 0 {
cc.ConnectTimeout = 10 * time.Second
}
if cc.QueryTimeout == 0 {
cc.QueryTimeout = 30 * time.Second
}
// Default ORM
if cc.DefaultORM == "" {
cc.DefaultORM = string(ORMTypeBun)
}
// Default PostgreSQL port
if cc.Type == DatabaseTypePostgreSQL && cc.Port == 0 && cc.DSN == "" {
cc.Port = 5432
}
// Default MSSQL port
if cc.Type == DatabaseTypeMSSQL && cc.Port == 0 && cc.DSN == "" {
cc.Port = 1433
}
// Default MongoDB port
if cc.Type == DatabaseTypeMongoDB && cc.Port == 0 && cc.DSN == "" {
cc.Port = 27017
}
// Default MongoDB auth source
if cc.Type == DatabaseTypeMongoDB && cc.AuthSource == "" {
cc.AuthSource = "admin"
}
}
// Validate validates the connection configuration
func (cc *ConnectionConfig) Validate() error {
// Validate database type
switch cc.Type {
case DatabaseTypePostgreSQL, DatabaseTypeSQLite, DatabaseTypeMSSQL, DatabaseTypeMongoDB:
// Valid types
default:
return NewConfigurationError("type", fmt.Errorf("unsupported database type: %s", cc.Type))
}
// Validate that either DSN or connection parameters are provided
if cc.DSN == "" {
switch cc.Type {
case DatabaseTypePostgreSQL, DatabaseTypeMSSQL, DatabaseTypeMongoDB:
if cc.Host == "" {
return NewConfigurationError("host", fmt.Errorf("host is required when DSN is not provided"))
}
if cc.Database == "" {
return NewConfigurationError("database", fmt.Errorf("database is required when DSN is not provided"))
}
case DatabaseTypeSQLite:
if cc.FilePath == "" {
return NewConfigurationError("filepath", fmt.Errorf("filepath is required for SQLite when DSN is not provided"))
}
}
}
// Validate ORM type
if cc.DefaultORM != "" {
switch ORMType(cc.DefaultORM) {
case ORMTypeBun, ORMTypeGORM, ORMTypeNative:
// Valid ORM types
default:
return NewConfigurationError("default_orm", fmt.Errorf("unsupported ORM type: %s", cc.DefaultORM))
}
}
return nil
}
// BuildDSN builds a connection string from individual parameters
func (cc *ConnectionConfig) BuildDSN() (string, error) {
// If DSN is already provided, use it
if cc.DSN != "" {
return cc.DSN, nil
}
switch cc.Type {
case DatabaseTypePostgreSQL:
return cc.buildPostgresDSN(), nil
case DatabaseTypeSQLite:
return cc.buildSQLiteDSN(), nil
case DatabaseTypeMSSQL:
return cc.buildMSSQLDSN(), nil
case DatabaseTypeMongoDB:
return cc.buildMongoDSN(), nil
default:
return "", fmt.Errorf("cannot build DSN for database type: %s", cc.Type)
}
}
func (cc *ConnectionConfig) buildPostgresDSN() string {
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s",
cc.Host, cc.Port, cc.User, cc.Password, cc.Database)
if cc.SSLMode != "" {
dsn += fmt.Sprintf(" sslmode=%s", cc.SSLMode)
} else {
dsn += " sslmode=disable"
}
if cc.Schema != "" {
dsn += fmt.Sprintf(" search_path=%s", cc.Schema)
}
return dsn
}
func (cc *ConnectionConfig) buildSQLiteDSN() string {
if cc.FilePath != "" {
return cc.FilePath
}
return ":memory:"
}
func (cc *ConnectionConfig) buildMSSQLDSN() string {
// Format: sqlserver://username:password@host:port?database=dbname
dsn := fmt.Sprintf("sqlserver://%s:%s@%s:%d?database=%s",
cc.User, cc.Password, cc.Host, cc.Port, cc.Database)
if cc.Schema != "" {
dsn += fmt.Sprintf("&schema=%s", cc.Schema)
}
return dsn
}
func (cc *ConnectionConfig) buildMongoDSN() string {
// Format: mongodb://username:password@host:port/database?authSource=admin
var dsn string
if cc.User != "" && cc.Password != "" {
dsn = fmt.Sprintf("mongodb://%s:%s@%s:%d/%s",
cc.User, cc.Password, cc.Host, cc.Port, cc.Database)
} else {
dsn = fmt.Sprintf("mongodb://%s:%d/%s", cc.Host, cc.Port, cc.Database)
}
params := ""
if cc.AuthSource != "" {
params += fmt.Sprintf("authSource=%s", cc.AuthSource)
}
if cc.ReplicaSet != "" {
if params != "" {
params += "&"
}
params += fmt.Sprintf("replicaSet=%s", cc.ReplicaSet)
}
if cc.ReadPreference != "" {
if params != "" {
params += "&"
}
params += fmt.Sprintf("readPreference=%s", cc.ReadPreference)
}
if params != "" {
dsn += "?" + params
}
return dsn
}
// FromConfig converts config.DBManagerConfig to internal ManagerConfig
func FromConfig(cfg config.DBManagerConfig) ManagerConfig {
mgr := ManagerConfig{
DefaultConnection: cfg.DefaultConnection,
Connections: make(map[string]ConnectionConfig),
MaxOpenConns: cfg.MaxOpenConns,
MaxIdleConns: cfg.MaxIdleConns,
ConnMaxLifetime: cfg.ConnMaxLifetime,
ConnMaxIdleTime: cfg.ConnMaxIdleTime,
RetryAttempts: cfg.RetryAttempts,
RetryDelay: cfg.RetryDelay,
RetryMaxDelay: cfg.RetryMaxDelay,
HealthCheckInterval: cfg.HealthCheckInterval,
EnableAutoReconnect: cfg.EnableAutoReconnect,
}
// Convert connections
for name := range cfg.Connections {
connCfg := cfg.Connections[name]
mgr.Connections[name] = ConnectionConfig{
Name: connCfg.Name,
Type: DatabaseType(connCfg.Type),
DSN: connCfg.DSN,
Host: connCfg.Host,
Port: connCfg.Port,
User: connCfg.User,
Password: connCfg.Password,
Database: connCfg.Database,
SSLMode: connCfg.SSLMode,
Schema: connCfg.Schema,
FilePath: connCfg.FilePath,
AuthSource: connCfg.AuthSource,
ReplicaSet: connCfg.ReplicaSet,
ReadPreference: connCfg.ReadPreference,
MaxOpenConns: connCfg.MaxOpenConns,
MaxIdleConns: connCfg.MaxIdleConns,
ConnMaxLifetime: connCfg.ConnMaxLifetime,
ConnMaxIdleTime: connCfg.ConnMaxIdleTime,
ConnectTimeout: connCfg.ConnectTimeout,
QueryTimeout: connCfg.QueryTimeout,
EnableTracing: connCfg.EnableTracing,
EnableMetrics: connCfg.EnableMetrics,
EnableLogging: connCfg.EnableLogging,
DefaultORM: connCfg.DefaultORM,
Tags: connCfg.Tags,
}
}
return mgr
}
// Getter methods to implement providers.ConnectionConfig interface
func (cc *ConnectionConfig) GetName() string { return cc.Name }
func (cc *ConnectionConfig) GetType() string { return string(cc.Type) }
func (cc *ConnectionConfig) GetHost() string { return cc.Host }
func (cc *ConnectionConfig) GetPort() int { return cc.Port }
func (cc *ConnectionConfig) GetUser() string { return cc.User }
func (cc *ConnectionConfig) GetPassword() string { return cc.Password }
func (cc *ConnectionConfig) GetDatabase() string { return cc.Database }
func (cc *ConnectionConfig) GetFilePath() string { return cc.FilePath }
func (cc *ConnectionConfig) GetConnectTimeout() time.Duration { return cc.ConnectTimeout }
func (cc *ConnectionConfig) GetEnableLogging() bool { return cc.EnableLogging }
func (cc *ConnectionConfig) GetMaxOpenConns() *int { return cc.MaxOpenConns }
func (cc *ConnectionConfig) GetMaxIdleConns() *int { return cc.MaxIdleConns }
func (cc *ConnectionConfig) GetConnMaxLifetime() *time.Duration { return cc.ConnMaxLifetime }
func (cc *ConnectionConfig) GetConnMaxIdleTime() *time.Duration { return cc.ConnMaxIdleTime }
func (cc *ConnectionConfig) GetQueryTimeout() time.Duration { return cc.QueryTimeout }
func (cc *ConnectionConfig) GetEnableMetrics() bool { return cc.EnableMetrics }
func (cc *ConnectionConfig) GetReadPreference() string { return cc.ReadPreference }

607
pkg/dbmanager/connection.go Normal file
View File

@@ -0,0 +1,607 @@
package dbmanager
import (
"context"
"database/sql"
"sync"
"time"
"github.com/uptrace/bun"
"github.com/uptrace/bun/schema"
"go.mongodb.org/mongo-driver/mongo"
"gorm.io/gorm"
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/common/adapters/database"
)
// Connection represents a single named database connection
type Connection interface {
// Metadata
Name() string
Type() DatabaseType
// ORM Access (SQL databases only)
Bun() (*bun.DB, error)
GORM() (*gorm.DB, error)
Native() (*sql.DB, error)
// Common Database interface (for SQL databases)
Database() (common.Database, error)
// MongoDB Access (MongoDB only)
MongoDB() (*mongo.Client, error)
// Lifecycle
Connect(ctx context.Context) error
Close() error
HealthCheck(ctx context.Context) error
Reconnect(ctx context.Context) error
// Stats
Stats() *ConnectionStats
}
// ConnectionStats contains statistics about a database connection
type ConnectionStats struct {
Name string
Type DatabaseType
Connected bool
LastHealthCheck time.Time
HealthCheckStatus string
// SQL connection pool stats
OpenConnections int
InUse int
Idle int
WaitCount int64
WaitDuration time.Duration
MaxIdleClosed int64
MaxLifetimeClosed int64
}
// sqlConnection implements Connection for SQL databases (PostgreSQL, SQLite, MSSQL)
type sqlConnection struct {
name string
dbType DatabaseType
config ConnectionConfig
provider Provider
// Lazy-initialized ORM instances (all wrap the same sql.DB)
nativeDB *sql.DB
bunDB *bun.DB
gormDB *gorm.DB
// Adapters for common.Database interface
bunAdapter *database.BunAdapter
gormAdapter *database.GormAdapter
nativeAdapter common.Database
// State
connected bool
mu sync.RWMutex
// Health check
lastHealthCheck time.Time
healthCheckStatus string
}
// newSQLConnection creates a new SQL connection
func newSQLConnection(name string, dbType DatabaseType, config ConnectionConfig, provider Provider) *sqlConnection {
return &sqlConnection{
name: name,
dbType: dbType,
config: config,
provider: provider,
}
}
// Name returns the connection name
func (c *sqlConnection) Name() string {
return c.name
}
// Type returns the database type
func (c *sqlConnection) Type() DatabaseType {
return c.dbType
}
// Connect establishes the database connection
func (c *sqlConnection) Connect(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.connected {
return ErrAlreadyConnected
}
if err := c.provider.Connect(ctx, &c.config); err != nil {
return NewConnectionError(c.name, "connect", err)
}
c.connected = true
return nil
}
// Close closes the database connection and all ORM instances
func (c *sqlConnection) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if !c.connected {
return nil
}
// Close Bun if initialized
if c.bunDB != nil {
if err := c.bunDB.Close(); err != nil {
return NewConnectionError(c.name, "close bun", err)
}
}
// GORM doesn't have a separate close - it uses the underlying sql.DB
// Close the provider (which closes the underlying sql.DB)
if err := c.provider.Close(); err != nil {
return NewConnectionError(c.name, "close", err)
}
c.connected = false
c.nativeDB = nil
c.bunDB = nil
c.gormDB = nil
c.bunAdapter = nil
c.gormAdapter = nil
c.nativeAdapter = nil
return nil
}
// HealthCheck verifies the connection is alive
func (c *sqlConnection) HealthCheck(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
c.lastHealthCheck = time.Now()
if !c.connected {
c.healthCheckStatus = "disconnected"
return ErrConnectionClosed
}
if err := c.provider.HealthCheck(ctx); err != nil {
c.healthCheckStatus = "unhealthy: " + err.Error()
return NewConnectionError(c.name, "health check", err)
}
c.healthCheckStatus = "healthy"
return nil
}
// Reconnect closes and re-establishes the connection
func (c *sqlConnection) Reconnect(ctx context.Context) error {
if err := c.Close(); err != nil {
return err
}
return c.Connect(ctx)
}
// Native returns the native *sql.DB connection
func (c *sqlConnection) Native() (*sql.DB, error) {
c.mu.RLock()
if c.nativeDB != nil {
defer c.mu.RUnlock()
return c.nativeDB, nil
}
c.mu.RUnlock()
c.mu.Lock()
defer c.mu.Unlock()
// Double-check after acquiring write lock
if c.nativeDB != nil {
return c.nativeDB, nil
}
if !c.connected {
return nil, ErrConnectionClosed
}
// Get native connection from provider
db, err := c.provider.GetNative()
if err != nil {
return nil, NewConnectionError(c.name, "get native", err)
}
c.nativeDB = db
return c.nativeDB, nil
}
// Bun returns a Bun ORM instance wrapping the native connection
func (c *sqlConnection) Bun() (*bun.DB, error) {
c.mu.RLock()
if c.bunDB != nil {
defer c.mu.RUnlock()
return c.bunDB, nil
}
c.mu.RUnlock()
c.mu.Lock()
defer c.mu.Unlock()
// Double-check after acquiring write lock
if c.bunDB != nil {
return c.bunDB, nil
}
// Get native connection first
native, err := c.provider.GetNative()
if err != nil {
return nil, NewConnectionError(c.name, "get bun", err)
}
// Create Bun DB wrapping the same sql.DB
dialect := c.getBunDialect()
c.bunDB = bun.NewDB(native, dialect)
return c.bunDB, nil
}
// GORM returns a GORM instance wrapping the native connection
func (c *sqlConnection) GORM() (*gorm.DB, error) {
c.mu.RLock()
if c.gormDB != nil {
defer c.mu.RUnlock()
return c.gormDB, nil
}
c.mu.RUnlock()
c.mu.Lock()
defer c.mu.Unlock()
// Double-check after acquiring write lock
if c.gormDB != nil {
return c.gormDB, nil
}
// Get native connection first
native, err := c.provider.GetNative()
if err != nil {
return nil, NewConnectionError(c.name, "get gorm", err)
}
// Create GORM DB wrapping the same sql.DB
dialector := c.getGORMDialector(native)
db, err := gorm.Open(dialector, &gorm.Config{})
if err != nil {
return nil, NewConnectionError(c.name, "initialize gorm", err)
}
c.gormDB = db
return c.gormDB, nil
}
// Database returns the common.Database interface using the configured default ORM
func (c *sqlConnection) Database() (common.Database, error) {
c.mu.RLock()
defaultORM := c.config.DefaultORM
c.mu.RUnlock()
switch ORMType(defaultORM) {
case ORMTypeBun:
return c.getBunAdapter()
case ORMTypeGORM:
return c.getGORMAdapter()
case ORMTypeNative:
return c.getNativeAdapter()
default:
// Default to Bun
return c.getBunAdapter()
}
}
// MongoDB returns an error for SQL connections
func (c *sqlConnection) MongoDB() (*mongo.Client, error) {
return nil, ErrNotMongoDB
}
// Stats returns connection statistics
func (c *sqlConnection) Stats() *ConnectionStats {
c.mu.RLock()
defer c.mu.RUnlock()
stats := &ConnectionStats{
Name: c.name,
Type: c.dbType,
Connected: c.connected,
LastHealthCheck: c.lastHealthCheck,
HealthCheckStatus: c.healthCheckStatus,
}
// Get SQL stats if connected
if c.connected && c.provider != nil {
if providerStats := c.provider.Stats(); providerStats != nil {
stats.OpenConnections = providerStats.OpenConnections
stats.InUse = providerStats.InUse
stats.Idle = providerStats.Idle
stats.WaitCount = providerStats.WaitCount
stats.WaitDuration = providerStats.WaitDuration
stats.MaxIdleClosed = providerStats.MaxIdleClosed
stats.MaxLifetimeClosed = providerStats.MaxLifetimeClosed
}
}
return stats
}
// getBunAdapter returns or creates the Bun adapter
func (c *sqlConnection) getBunAdapter() (common.Database, error) {
c.mu.RLock()
if c.bunAdapter != nil {
defer c.mu.RUnlock()
return c.bunAdapter, nil
}
c.mu.RUnlock()
c.mu.Lock()
defer c.mu.Unlock()
if c.bunAdapter != nil {
return c.bunAdapter, nil
}
bunDB, err := c.Bun()
if err != nil {
return nil, err
}
c.bunAdapter = database.NewBunAdapter(bunDB)
return c.bunAdapter, nil
}
// getGORMAdapter returns or creates the GORM adapter
func (c *sqlConnection) getGORMAdapter() (common.Database, error) {
c.mu.RLock()
if c.gormAdapter != nil {
defer c.mu.RUnlock()
return c.gormAdapter, nil
}
c.mu.RUnlock()
c.mu.Lock()
defer c.mu.Unlock()
if c.gormAdapter != nil {
return c.gormAdapter, nil
}
gormDB, err := c.GORM()
if err != nil {
return nil, err
}
c.gormAdapter = database.NewGormAdapter(gormDB)
return c.gormAdapter, nil
}
// getNativeAdapter returns or creates the native adapter
func (c *sqlConnection) getNativeAdapter() (common.Database, error) {
c.mu.RLock()
if c.nativeAdapter != nil {
defer c.mu.RUnlock()
return c.nativeAdapter, nil
}
c.mu.RUnlock()
c.mu.Lock()
defer c.mu.Unlock()
if c.nativeAdapter != nil {
return c.nativeAdapter, nil
}
native, err := c.Native()
if err != nil {
return nil, err
}
// Create a native adapter based on database type
switch c.dbType {
case DatabaseTypePostgreSQL:
c.nativeAdapter = database.NewPgSQLAdapter(native)
case DatabaseTypeSQLite:
// For SQLite, we'll use the PgSQL adapter as it works with standard sql.DB
c.nativeAdapter = database.NewPgSQLAdapter(native)
case DatabaseTypeMSSQL:
// For MSSQL, we'll use the PgSQL adapter as it works with standard sql.DB
c.nativeAdapter = database.NewPgSQLAdapter(native)
default:
return nil, ErrUnsupportedDatabase
}
return c.nativeAdapter, nil
}
// getBunDialect returns the appropriate Bun dialect for the database type
func (c *sqlConnection) getBunDialect() schema.Dialect {
switch c.dbType {
case DatabaseTypePostgreSQL:
return database.GetPostgresDialect()
case DatabaseTypeSQLite:
return database.GetSQLiteDialect()
case DatabaseTypeMSSQL:
return database.GetMSSQLDialect()
default:
// Default to PostgreSQL
return database.GetPostgresDialect()
}
}
// getGORMDialector returns the appropriate GORM dialector for the database type
func (c *sqlConnection) getGORMDialector(db *sql.DB) gorm.Dialector {
switch c.dbType {
case DatabaseTypePostgreSQL:
return database.GetPostgresDialector(db)
case DatabaseTypeSQLite:
return database.GetSQLiteDialector(db)
case DatabaseTypeMSSQL:
return database.GetMSSQLDialector(db)
default:
// Default to PostgreSQL
return database.GetPostgresDialector(db)
}
}
// mongoConnection implements Connection for MongoDB
type mongoConnection struct {
name string
config ConnectionConfig
provider Provider
// MongoDB client
client *mongo.Client
// State
connected bool
mu sync.RWMutex
// Health check
lastHealthCheck time.Time
healthCheckStatus string
}
// newMongoConnection creates a new MongoDB connection
func newMongoConnection(name string, config ConnectionConfig, provider Provider) *mongoConnection {
return &mongoConnection{
name: name,
config: config,
provider: provider,
}
}
// Name returns the connection name
func (c *mongoConnection) Name() string {
return c.name
}
// Type returns the database type (MongoDB)
func (c *mongoConnection) Type() DatabaseType {
return DatabaseTypeMongoDB
}
// Connect establishes the MongoDB connection
func (c *mongoConnection) Connect(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.connected {
return ErrAlreadyConnected
}
if err := c.provider.Connect(ctx, &c.config); err != nil {
return NewConnectionError(c.name, "connect", err)
}
// Get the mongo client
client, err := c.provider.GetMongo()
if err != nil {
return NewConnectionError(c.name, "get mongo client", err)
}
c.client = client
c.connected = true
return nil
}
// Close closes the MongoDB connection
func (c *mongoConnection) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if !c.connected {
return nil
}
if err := c.provider.Close(); err != nil {
return NewConnectionError(c.name, "close", err)
}
c.connected = false
c.client = nil
return nil
}
// HealthCheck verifies the MongoDB connection is alive
func (c *mongoConnection) HealthCheck(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
c.lastHealthCheck = time.Now()
if !c.connected {
c.healthCheckStatus = "disconnected"
return ErrConnectionClosed
}
if err := c.provider.HealthCheck(ctx); err != nil {
c.healthCheckStatus = "unhealthy: " + err.Error()
return NewConnectionError(c.name, "health check", err)
}
c.healthCheckStatus = "healthy"
return nil
}
// Reconnect closes and re-establishes the MongoDB connection
func (c *mongoConnection) Reconnect(ctx context.Context) error {
if err := c.Close(); err != nil {
return err
}
return c.Connect(ctx)
}
// MongoDB returns the MongoDB client
func (c *mongoConnection) MongoDB() (*mongo.Client, error) {
c.mu.RLock()
defer c.mu.RUnlock()
if !c.connected || c.client == nil {
return nil, ErrConnectionClosed
}
return c.client, nil
}
// Bun returns an error for MongoDB connections
func (c *mongoConnection) Bun() (*bun.DB, error) {
return nil, ErrNotSQLDatabase
}
// GORM returns an error for MongoDB connections
func (c *mongoConnection) GORM() (*gorm.DB, error) {
return nil, ErrNotSQLDatabase
}
// Native returns an error for MongoDB connections
func (c *mongoConnection) Native() (*sql.DB, error) {
return nil, ErrNotSQLDatabase
}
// Database returns an error for MongoDB connections
func (c *mongoConnection) Database() (common.Database, error) {
return nil, ErrNotSQLDatabase
}
// Stats returns connection statistics for MongoDB
func (c *mongoConnection) Stats() *ConnectionStats {
c.mu.RLock()
defer c.mu.RUnlock()
return &ConnectionStats{
Name: c.name,
Type: DatabaseTypeMongoDB,
Connected: c.connected,
LastHealthCheck: c.lastHealthCheck,
HealthCheckStatus: c.healthCheckStatus,
}
}

82
pkg/dbmanager/errors.go Normal file
View File

@@ -0,0 +1,82 @@
package dbmanager
import (
"errors"
"fmt"
)
// Common errors
var (
// ErrConnectionNotFound is returned when a connection with the given name doesn't exist
ErrConnectionNotFound = errors.New("connection not found")
// ErrInvalidConfiguration is returned when the configuration is invalid
ErrInvalidConfiguration = errors.New("invalid configuration")
// ErrConnectionClosed is returned when attempting to use a closed connection
ErrConnectionClosed = errors.New("connection is closed")
// ErrNotSQLDatabase is returned when attempting SQL operations on a non-SQL database
ErrNotSQLDatabase = errors.New("not a SQL database")
// ErrNotMongoDB is returned when attempting MongoDB operations on a non-MongoDB connection
ErrNotMongoDB = errors.New("not a MongoDB connection")
// ErrUnsupportedDatabase is returned when the database type is not supported
ErrUnsupportedDatabase = errors.New("unsupported database type")
// ErrNoDefaultConnection is returned when no default connection is configured
ErrNoDefaultConnection = errors.New("no default connection configured")
// ErrAlreadyConnected is returned when attempting to connect an already connected connection
ErrAlreadyConnected = errors.New("already connected")
)
// ConnectionError wraps errors that occur during connection operations
type ConnectionError struct {
Name string
Operation string
Err error
}
func (e *ConnectionError) Error() string {
return fmt.Sprintf("connection '%s' %s: %v", e.Name, e.Operation, e.Err)
}
func (e *ConnectionError) Unwrap() error {
return e.Err
}
// NewConnectionError creates a new ConnectionError
func NewConnectionError(name, operation string, err error) *ConnectionError {
return &ConnectionError{
Name: name,
Operation: operation,
Err: err,
}
}
// ConfigurationError wraps configuration-related errors
type ConfigurationError struct {
Field string
Err error
}
func (e *ConfigurationError) Error() string {
if e.Field != "" {
return fmt.Sprintf("configuration error in field '%s': %v", e.Field, e.Err)
}
return fmt.Sprintf("configuration error: %v", e.Err)
}
func (e *ConfigurationError) Unwrap() error {
return e.Err
}
// NewConfigurationError creates a new ConfigurationError
func NewConfigurationError(field string, err error) *ConfigurationError {
return &ConfigurationError{
Field: field,
Err: err,
}
}

51
pkg/dbmanager/factory.go Normal file
View File

@@ -0,0 +1,51 @@
package dbmanager
import (
"fmt"
"github.com/bitechdev/ResolveSpec/pkg/dbmanager/providers"
)
// createConnection creates a database connection based on the configuration
func createConnection(cfg ConnectionConfig) (Connection, error) {
// Validate configuration
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid connection configuration: %w", err)
}
// Create provider based on database type
provider, err := createProvider(cfg.Type)
if err != nil {
return nil, err
}
// Create connection wrapper based on database type
switch cfg.Type {
case DatabaseTypePostgreSQL, DatabaseTypeSQLite, DatabaseTypeMSSQL:
return newSQLConnection(cfg.Name, cfg.Type, cfg, provider), nil
case DatabaseTypeMongoDB:
return newMongoConnection(cfg.Name, cfg, provider), nil
default:
return nil, fmt.Errorf("%w: %s", ErrUnsupportedDatabase, cfg.Type)
}
}
// createProvider creates a database provider based on the database type
func createProvider(dbType DatabaseType) (Provider, error) {
switch dbType {
case DatabaseTypePostgreSQL:
return providers.NewPostgresProvider(), nil
case DatabaseTypeSQLite:
return providers.NewSQLiteProvider(), nil
case DatabaseTypeMSSQL:
return providers.NewMSSQLProvider(), nil
case DatabaseTypeMongoDB:
return providers.NewMongoProvider(), nil
default:
return nil, fmt.Errorf("%w: %s", ErrUnsupportedDatabase, dbType)
}
}
// Provider is an alias to the providers.Provider interface
// This allows dbmanager package consumers to use Provider without importing providers
type Provider = providers.Provider

326
pkg/dbmanager/manager.go Normal file
View File

@@ -0,0 +1,326 @@
package dbmanager
import (
"context"
"fmt"
"sync"
"time"
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/logger"
)
// Manager manages multiple named database connections
type Manager interface {
// Connection retrieval
Get(name string) (Connection, error)
GetDefault() (Connection, error)
GetAll() map[string]Connection
// Default database management
GetDefaultDatabase() (common.Database, error)
SetDefaultDatabase(name string) error
// Lifecycle
Connect(ctx context.Context) error
Close() error
HealthCheck(ctx context.Context) error
// Stats
Stats() *ManagerStats
}
// ManagerStats contains statistics about the connection manager
type ManagerStats struct {
TotalConnections int
HealthyCount int
UnhealthyCount int
ConnectionStats map[string]*ConnectionStats
}
// connectionManager implements Manager
type connectionManager struct {
connections map[string]Connection
config ManagerConfig
mu sync.RWMutex
// Background health check
healthTicker *time.Ticker
stopChan chan struct{}
wg sync.WaitGroup
}
// NewManager creates a new database connection manager
func NewManager(cfg ManagerConfig) (Manager, error) {
// Apply defaults and validate configuration
cfg.ApplyDefaults()
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
mgr := &connectionManager{
connections: make(map[string]Connection),
config: cfg,
stopChan: make(chan struct{}),
}
return mgr, nil
}
// Get retrieves a named connection
func (m *connectionManager) Get(name string) (Connection, error) {
m.mu.RLock()
defer m.mu.RUnlock()
conn, ok := m.connections[name]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrConnectionNotFound, name)
}
return conn, nil
}
// GetDefault retrieves the default connection
func (m *connectionManager) GetDefault() (Connection, error) {
m.mu.RLock()
defaultName := m.config.DefaultConnection
m.mu.RUnlock()
if defaultName == "" {
return nil, ErrNoDefaultConnection
}
return m.Get(defaultName)
}
// GetAll returns all connections
func (m *connectionManager) GetAll() map[string]Connection {
m.mu.RLock()
defer m.mu.RUnlock()
// Create a copy to avoid concurrent access issues
result := make(map[string]Connection, len(m.connections))
for name, conn := range m.connections {
result[name] = conn
}
return result
}
// GetDefaultDatabase returns the common.Database interface from the default connection
func (m *connectionManager) GetDefaultDatabase() (common.Database, error) {
conn, err := m.GetDefault()
if err != nil {
return nil, err
}
db, err := conn.Database()
if err != nil {
return nil, fmt.Errorf("failed to get database from default connection: %w", err)
}
return db, nil
}
// SetDefaultDatabase sets the default database connection by name
func (m *connectionManager) SetDefaultDatabase(name string) error {
m.mu.Lock()
defer m.mu.Unlock()
// Verify the connection exists
if _, ok := m.connections[name]; !ok {
return fmt.Errorf("%w: %s", ErrConnectionNotFound, name)
}
m.config.DefaultConnection = name
logger.Info("Default database connection changed: name=%s", name)
return nil
}
// Connect establishes all configured database connections
func (m *connectionManager) Connect(ctx context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
// Create connections from configuration
for name := range m.config.Connections {
// Get a copy of the connection config
connCfg := m.config.Connections[name]
// Apply global defaults to connection config
connCfg.ApplyDefaults(&m.config)
connCfg.Name = name
// Create connection using factory
conn, err := createConnection(connCfg)
if err != nil {
return fmt.Errorf("failed to create connection '%s': %w", name, err)
}
// Connect
if err := conn.Connect(ctx); err != nil {
return fmt.Errorf("failed to connect '%s': %w", name, err)
}
m.connections[name] = conn
logger.Info("Database connection established: name=%s, type=%s", name, connCfg.Type)
}
// Start background health checks if enabled
if m.config.EnableAutoReconnect && m.config.HealthCheckInterval > 0 {
m.startHealthChecker()
}
logger.Info("Database manager initialized: connections=%d", len(m.connections))
return nil
}
// Close closes all database connections
func (m *connectionManager) Close() error {
m.mu.Lock()
defer m.mu.Unlock()
// Stop health checker
m.stopHealthChecker()
// Close all connections
var errors []error
for name, conn := range m.connections {
if err := conn.Close(); err != nil {
errors = append(errors, fmt.Errorf("failed to close connection '%s': %w", name, err))
logger.Error("Failed to close connection", "name", name, "error", err)
} else {
logger.Info("Connection closed: name=%s", name)
}
}
m.connections = make(map[string]Connection)
if len(errors) > 0 {
return fmt.Errorf("errors closing connections: %v", errors)
}
logger.Info("Database manager closed")
return nil
}
// HealthCheck performs health checks on all connections
func (m *connectionManager) HealthCheck(ctx context.Context) error {
m.mu.RLock()
connections := make(map[string]Connection, len(m.connections))
for name, conn := range m.connections {
connections[name] = conn
}
m.mu.RUnlock()
var errors []error
for name, conn := range connections {
if err := conn.HealthCheck(ctx); err != nil {
errors = append(errors, fmt.Errorf("connection '%s': %w", name, err))
}
}
if len(errors) > 0 {
return fmt.Errorf("health check failed for %d connections: %v", len(errors), errors)
}
return nil
}
// Stats returns statistics for all connections
func (m *connectionManager) Stats() *ManagerStats {
m.mu.RLock()
defer m.mu.RUnlock()
stats := &ManagerStats{
TotalConnections: len(m.connections),
ConnectionStats: make(map[string]*ConnectionStats),
}
for name, conn := range m.connections {
connStats := conn.Stats()
stats.ConnectionStats[name] = connStats
if connStats.Connected && connStats.HealthCheckStatus == "healthy" {
stats.HealthyCount++
} else {
stats.UnhealthyCount++
}
}
return stats
}
// startHealthChecker starts background health checking
func (m *connectionManager) startHealthChecker() {
if m.healthTicker != nil {
return // Already running
}
m.healthTicker = time.NewTicker(m.config.HealthCheckInterval)
m.wg.Add(1)
go func() {
defer m.wg.Done()
logger.Info("Health checker started: interval=%v", m.config.HealthCheckInterval)
for {
select {
case <-m.healthTicker.C:
m.performHealthCheck()
case <-m.stopChan:
logger.Info("Health checker stopped")
return
}
}
}()
}
// stopHealthChecker stops background health checking
func (m *connectionManager) stopHealthChecker() {
if m.healthTicker != nil {
m.healthTicker.Stop()
close(m.stopChan)
m.wg.Wait()
m.healthTicker = nil
}
}
// performHealthCheck performs a health check on all connections
func (m *connectionManager) performHealthCheck() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
m.mu.RLock()
connections := make([]struct {
name string
conn Connection
}, 0, len(m.connections))
for name, conn := range m.connections {
connections = append(connections, struct {
name string
conn Connection
}{name, conn})
}
m.mu.RUnlock()
for _, item := range connections {
if err := item.conn.HealthCheck(ctx); err != nil {
logger.Warn("Health check failed",
"connection", item.name,
"error", err)
// Attempt reconnection if enabled
if m.config.EnableAutoReconnect {
logger.Info("Attempting reconnection: connection=%s", item.name)
if err := item.conn.Reconnect(ctx); err != nil {
logger.Error("Reconnection failed",
"connection", item.name,
"error", err)
} else {
logger.Info("Reconnection successful: connection=%s", item.name)
}
}
}
}
}

136
pkg/dbmanager/metrics.go Normal file
View File

@@ -0,0 +1,136 @@
package dbmanager
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
// connectionsTotal tracks the total number of configured database connections
connectionsTotal = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "dbmanager_connections_total",
Help: "Total number of configured database connections",
},
[]string{"type"},
)
// connectionStatus tracks connection health status (1=healthy, 0=unhealthy)
connectionStatus = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "dbmanager_connection_status",
Help: "Connection status (1=healthy, 0=unhealthy)",
},
[]string{"name", "type"},
)
// connectionPoolSize tracks connection pool sizes
connectionPoolSize = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "dbmanager_connection_pool_size",
Help: "Current connection pool size",
},
[]string{"name", "type", "state"}, // state: open, idle, in_use
)
// connectionWaitCount tracks how many times connections had to wait for availability
connectionWaitCount = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "dbmanager_connection_wait_count",
Help: "Number of times connections had to wait for availability",
},
[]string{"name", "type"},
)
// connectionWaitDuration tracks total time connections spent waiting
connectionWaitDuration = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "dbmanager_connection_wait_duration_seconds",
Help: "Total time connections spent waiting for availability",
},
[]string{"name", "type"},
)
// reconnectAttempts tracks reconnection attempts and their outcomes
reconnectAttempts = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "dbmanager_reconnect_attempts_total",
Help: "Total number of reconnection attempts",
},
[]string{"name", "type", "result"}, // result: success, failure
)
// connectionLifetimeClosed tracks connections closed due to max lifetime
connectionLifetimeClosed = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "dbmanager_connection_lifetime_closed_total",
Help: "Total connections closed due to exceeding max lifetime",
},
[]string{"name", "type"},
)
// connectionIdleClosed tracks connections closed due to max idle time
connectionIdleClosed = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "dbmanager_connection_idle_closed_total",
Help: "Total connections closed due to exceeding max idle time",
},
[]string{"name", "type"},
)
)
// PublishMetrics publishes current metrics for all connections
func (m *connectionManager) PublishMetrics() {
stats := m.Stats()
// Count connections by type
typeCount := make(map[DatabaseType]int)
for _, connStats := range stats.ConnectionStats {
typeCount[connStats.Type]++
}
// Update total connections gauge
for dbType, count := range typeCount {
connectionsTotal.WithLabelValues(string(dbType)).Set(float64(count))
}
// Update per-connection metrics
for name, connStats := range stats.ConnectionStats {
labels := prometheus.Labels{
"name": name,
"type": string(connStats.Type),
}
// Connection status
status := float64(0)
if connStats.Connected && connStats.HealthCheckStatus == "healthy" {
status = 1
}
connectionStatus.With(labels).Set(status)
// Pool size metrics (SQL databases only)
if connStats.Type != DatabaseTypeMongoDB {
connectionPoolSize.WithLabelValues(name, string(connStats.Type), "open").Set(float64(connStats.OpenConnections))
connectionPoolSize.WithLabelValues(name, string(connStats.Type), "idle").Set(float64(connStats.Idle))
connectionPoolSize.WithLabelValues(name, string(connStats.Type), "in_use").Set(float64(connStats.InUse))
// Wait stats
connectionWaitCount.With(labels).Set(float64(connStats.WaitCount))
connectionWaitDuration.With(labels).Set(connStats.WaitDuration.Seconds())
// Lifetime/idle closed
connectionLifetimeClosed.With(labels).Set(float64(connStats.MaxLifetimeClosed))
connectionIdleClosed.With(labels).Set(float64(connStats.MaxIdleClosed))
}
}
}
// RecordReconnectAttempt records a reconnection attempt
func RecordReconnectAttempt(name string, dbType DatabaseType, success bool) {
result := "failure"
if success {
result = "success"
}
reconnectAttempts.WithLabelValues(name, string(dbType), result).Inc()
}

View File

@@ -0,0 +1,319 @@
# PostgreSQL NOTIFY/LISTEN Support
The `dbmanager` package provides built-in support for PostgreSQL's NOTIFY/LISTEN functionality through the `PostgresListener` type.
## Overview
PostgreSQL NOTIFY/LISTEN is a simple pub/sub mechanism that allows database clients to:
- **LISTEN** on named channels to receive notifications
- **NOTIFY** channels to send messages to all listeners
- Receive asynchronous notifications without polling
## Features
- ✅ Subscribe to multiple channels simultaneously
- ✅ Callback-based notification handling
- ✅ Automatic reconnection on connection loss
- ✅ Automatic resubscription after reconnection
- ✅ Thread-safe operations
- ✅ Panic recovery in notification handlers
- ✅ Dedicated connection for listening (doesn't interfere with queries)
## Usage
### Basic Usage
```go
package main
import (
"context"
"fmt"
"time"
"github.com/bitechdev/ResolveSpec/pkg/dbmanager/providers"
)
func main() {
// Create PostgreSQL provider
cfg := &providers.Config{
Name: "primary",
Type: "postgres",
Host: "localhost",
Port: 5432,
User: "postgres",
Password: "password",
Database: "myapp",
}
provider := providers.NewPostgresProvider()
ctx := context.Background()
if err := provider.Connect(ctx, cfg); err != nil {
panic(err)
}
defer provider.Close()
// Get listener
listener, err := provider.GetListener(ctx)
if err != nil {
panic(err)
}
// Subscribe to a channel
err = listener.Listen("events", func(channel, payload string) {
fmt.Printf("Received on %s: %s\n", channel, payload)
})
if err != nil {
panic(err)
}
// Send a notification
err = listener.Notify(ctx, "events", "Hello, World!")
if err != nil {
panic(err)
}
// Keep the program running
time.Sleep(1 * time.Second)
}
```
### Multiple Channels
```go
listener, _ := provider.GetListener(ctx)
// Listen to different channels with different handlers
listener.Listen("user_events", func(channel, payload string) {
fmt.Printf("User event: %s\n", payload)
})
listener.Listen("order_events", func(channel, payload string) {
fmt.Printf("Order event: %s\n", payload)
})
listener.Listen("payment_events", func(channel, payload string) {
fmt.Printf("Payment event: %s\n", payload)
})
```
### Unsubscribing
```go
// Stop listening to a specific channel
err := listener.Unlisten("user_events")
if err != nil {
fmt.Printf("Failed to unlisten: %v\n", err)
}
```
### Checking Active Channels
```go
// Get list of channels currently being listened to
channels := listener.Channels()
fmt.Printf("Listening to: %v\n", channels)
```
### Checking Connection Status
```go
if listener.IsConnected() {
fmt.Println("Listener is connected")
} else {
fmt.Println("Listener is disconnected")
}
```
## Integration with DBManager
When using the DBManager, you can access the listener through the PostgreSQL provider:
```go
// Initialize DBManager
mgr, err := dbmanager.NewManager(dbmanager.FromConfig(cfg.DBManager))
mgr.Connect(ctx)
defer mgr.Close()
// Get PostgreSQL connection
conn, err := mgr.Get("primary")
// Note: You'll need to cast to the underlying provider type
// This requires exposing the provider through the Connection interface
// or providing a helper method
```
## Use Cases
### Cache Invalidation
```go
listener.Listen("cache_invalidation", func(channel, payload string) {
// Parse the payload to determine what to invalidate
cache.Invalidate(payload)
})
```
### Real-time Updates
```go
listener.Listen("data_updates", func(channel, payload string) {
// Broadcast update to WebSocket clients
websocketBroadcast(payload)
})
```
### Configuration Reload
```go
listener.Listen("config_reload", func(channel, payload string) {
// Reload application configuration
config.Reload()
})
```
### Distributed Locking
```go
listener.Listen("lock_released", func(channel, payload string) {
// Attempt to acquire the lock
tryAcquireLock(payload)
})
```
## Automatic Reconnection
The listener automatically handles connection failures:
1. When a connection error is detected, the listener initiates reconnection
2. Once reconnected, it automatically resubscribes to all previous channels
3. Notification handlers remain active throughout the reconnection process
No manual intervention is required for reconnection.
## Error Handling
### Handler Panics
If a notification handler panics, the panic is recovered and logged. The listener continues to function normally:
```go
listener.Listen("events", func(channel, payload string) {
defer func() {
if r := recover(); r != nil {
log.Printf("Handler panic: %v", r)
}
}()
// Your event processing logic
processEvent(payload)
})
```
### Connection Errors
Connection errors trigger automatic reconnection. Check logs for reconnection events when `EnableLogging` is true.
## Thread Safety
All `PostgresListener` methods are thread-safe and can be called concurrently from multiple goroutines.
## Performance Considerations
1. **Dedicated Connection**: The listener uses a dedicated PostgreSQL connection separate from the query connection pool
2. **Asynchronous Handlers**: Notification handlers run in separate goroutines to avoid blocking
3. **Lightweight**: NOTIFY/LISTEN has minimal overhead compared to polling
## Comparison with Polling
| Feature | NOTIFY/LISTEN | Polling |
|---------|---------------|---------|
| Latency | Low (near real-time) | High (depends on poll interval) |
| Database Load | Minimal | High (constant queries) |
| Scalability | Excellent | Poor |
| Complexity | Simple | Moderate |
## Limitations
1. **PostgreSQL Only**: This feature is specific to PostgreSQL and not available for other databases
2. **No Message Persistence**: Notifications are not stored; if no listener is connected, the message is lost
3. **Payload Limit**: Notification payload is limited to 8000 bytes in PostgreSQL
4. **No Guaranteed Delivery**: If a listener disconnects, in-flight notifications may be lost
## Best Practices
1. **Keep Handlers Fast**: Notification handlers should be quick; for heavy processing, send work to a queue
2. **Use JSON Payloads**: Encode structured data as JSON for easy parsing
3. **Handle Errors Gracefully**: Always recover from panics in handlers
4. **Close Properly**: Always close the provider to ensure the listener is properly shut down
5. **Monitor Connection Status**: Use `IsConnected()` for health checks
## Example: Real-World Application
```go
// Subscribe to various application events
listener, _ := provider.GetListener(ctx)
// User registration events
listener.Listen("user_registered", func(channel, payload string) {
var event UserRegisteredEvent
json.Unmarshal([]byte(payload), &event)
// Send welcome email
sendWelcomeEmail(event.UserID)
// Invalidate user count cache
cache.Delete("user_count")
})
// Order placement events
listener.Listen("order_placed", func(channel, payload string) {
var event OrderPlacedEvent
json.Unmarshal([]byte(payload), &event)
// Notify warehouse system
warehouse.ProcessOrder(event.OrderID)
// Update inventory cache
cache.Invalidate("inventory:" + event.ProductID)
})
// Configuration changes
listener.Listen("config_updated", func(channel, payload string) {
// Reload configuration from database
appConfig.Reload()
})
```
## Triggering Notifications from SQL
You can trigger notifications directly from PostgreSQL triggers or functions:
```sql
-- Example trigger to notify on new user
CREATE OR REPLACE FUNCTION notify_user_registered()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('user_registered',
json_build_object(
'user_id', NEW.id,
'email', NEW.email,
'timestamp', NOW()
)::text
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER user_registered_trigger
AFTER INSERT ON users
FOR EACH ROW
EXECUTE FUNCTION notify_user_registered();
```
## Additional Resources
- [PostgreSQL NOTIFY Documentation](https://www.postgresql.org/docs/current/sql-notify.html)
- [PostgreSQL LISTEN Documentation](https://www.postgresql.org/docs/current/sql-listen.html)
- [pgx Driver Documentation](https://github.com/jackc/pgx)

View File

@@ -0,0 +1,214 @@
package providers
import (
"context"
"database/sql"
"fmt"
"time"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"go.mongodb.org/mongo-driver/mongo/readpref"
"github.com/bitechdev/ResolveSpec/pkg/logger"
)
// MongoProvider implements Provider for MongoDB databases
type MongoProvider struct {
client *mongo.Client
config ConnectionConfig
}
// NewMongoProvider creates a new MongoDB provider
func NewMongoProvider() *MongoProvider {
return &MongoProvider{}
}
// Connect establishes a MongoDB connection
func (p *MongoProvider) Connect(ctx context.Context, cfg ConnectionConfig) error {
// Build DSN
dsn, err := cfg.BuildDSN()
if err != nil {
return fmt.Errorf("failed to build DSN: %w", err)
}
// Create client options
clientOpts := options.Client().ApplyURI(dsn)
// Set connection pool size
if cfg.GetMaxOpenConns() != nil {
maxPoolSize := uint64(*cfg.GetMaxOpenConns())
clientOpts.SetMaxPoolSize(maxPoolSize)
}
if cfg.GetMaxIdleConns() != nil {
minPoolSize := uint64(*cfg.GetMaxIdleConns())
clientOpts.SetMinPoolSize(minPoolSize)
}
// Set timeouts
clientOpts.SetConnectTimeout(cfg.GetConnectTimeout())
if cfg.GetQueryTimeout() > 0 {
clientOpts.SetTimeout(cfg.GetQueryTimeout())
}
// Set read preference if specified
if cfg.GetReadPreference() != "" {
rp, err := parseReadPreference(cfg.GetReadPreference())
if err != nil {
return fmt.Errorf("invalid read preference: %w", err)
}
clientOpts.SetReadPreference(rp)
}
// Connect with retry logic
var client *mongo.Client
var lastErr error
retryAttempts := 3
retryDelay := 1 * time.Second
for attempt := 0; attempt < retryAttempts; attempt++ {
if attempt > 0 {
delay := calculateBackoff(attempt, retryDelay, 10*time.Second)
if cfg.GetEnableLogging() {
logger.Info("Retrying MongoDB connection: attempt=%d/%d, delay=%v", attempt+1, retryAttempts, delay)
}
select {
case <-time.After(delay):
case <-ctx.Done():
return ctx.Err()
}
}
// Create MongoDB client
client, err = mongo.Connect(ctx, clientOpts)
if err != nil {
lastErr = err
if cfg.GetEnableLogging() {
logger.Warn("Failed to connect to MongoDB", "error", err)
}
continue
}
// Ping the database to verify connection
pingCtx, cancel := context.WithTimeout(ctx, cfg.GetConnectTimeout())
err = client.Ping(pingCtx, readpref.Primary())
cancel()
if err != nil {
lastErr = err
_ = client.Disconnect(ctx)
if cfg.GetEnableLogging() {
logger.Warn("Failed to ping MongoDB", "error", err)
}
continue
}
// Connection successful
break
}
if err != nil {
return fmt.Errorf("failed to connect after %d attempts: %w", retryAttempts, lastErr)
}
p.client = client
p.config = cfg
if cfg.GetEnableLogging() {
logger.Info("MongoDB connection established: name=%s, host=%s, database=%s", cfg.GetName(), cfg.GetHost(), cfg.GetDatabase())
}
return nil
}
// Close closes the MongoDB connection
func (p *MongoProvider) Close() error {
if p.client == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := p.client.Disconnect(ctx)
if err != nil {
return fmt.Errorf("failed to close MongoDB connection: %w", err)
}
if p.config.GetEnableLogging() {
logger.Info("MongoDB connection closed: name=%s", p.config.GetName())
}
p.client = nil
return nil
}
// HealthCheck verifies the MongoDB connection is alive
func (p *MongoProvider) HealthCheck(ctx context.Context) error {
if p.client == nil {
return fmt.Errorf("MongoDB client is nil")
}
// Use a short timeout for health checks
healthCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := p.client.Ping(healthCtx, readpref.Primary()); err != nil {
return fmt.Errorf("health check failed: %w", err)
}
return nil
}
// GetNative returns an error for MongoDB (not a SQL database)
func (p *MongoProvider) GetNative() (*sql.DB, error) {
return nil, ErrNotSQLDatabase
}
// GetMongo returns the MongoDB client
func (p *MongoProvider) GetMongo() (*mongo.Client, error) {
if p.client == nil {
return nil, fmt.Errorf("MongoDB client is not initialized")
}
return p.client, nil
}
// Stats returns connection statistics for MongoDB
func (p *MongoProvider) Stats() *ConnectionStats {
if p.client == nil {
return &ConnectionStats{
Name: p.config.GetName(),
Type: "mongodb",
Connected: false,
}
}
// MongoDB doesn't expose detailed connection pool stats like sql.DB
// We return basic stats
return &ConnectionStats{
Name: p.config.GetName(),
Type: "mongodb",
Connected: true,
}
}
// parseReadPreference parses a read preference string into a readpref.ReadPref
func parseReadPreference(rp string) (*readpref.ReadPref, error) {
switch rp {
case "primary":
return readpref.Primary(), nil
case "primaryPreferred":
return readpref.PrimaryPreferred(), nil
case "secondary":
return readpref.Secondary(), nil
case "secondaryPreferred":
return readpref.SecondaryPreferred(), nil
case "nearest":
return readpref.Nearest(), nil
default:
return nil, fmt.Errorf("unknown read preference: %s", rp)
}
}

View File

@@ -0,0 +1,184 @@
package providers
import (
"context"
"database/sql"
"fmt"
"time"
_ "github.com/microsoft/go-mssqldb" // MSSQL driver
"go.mongodb.org/mongo-driver/mongo"
"github.com/bitechdev/ResolveSpec/pkg/logger"
)
// MSSQLProvider implements Provider for Microsoft SQL Server databases
type MSSQLProvider struct {
db *sql.DB
config ConnectionConfig
}
// NewMSSQLProvider creates a new MSSQL provider
func NewMSSQLProvider() *MSSQLProvider {
return &MSSQLProvider{}
}
// Connect establishes a MSSQL connection
func (p *MSSQLProvider) Connect(ctx context.Context, cfg ConnectionConfig) error {
// Build DSN
dsn, err := cfg.BuildDSN()
if err != nil {
return fmt.Errorf("failed to build DSN: %w", err)
}
// Connect with retry logic
var db *sql.DB
var lastErr error
retryAttempts := 3 // Default retry attempts
retryDelay := 1 * time.Second
for attempt := 0; attempt < retryAttempts; attempt++ {
if attempt > 0 {
delay := calculateBackoff(attempt, retryDelay, 10*time.Second)
if cfg.GetEnableLogging() {
logger.Info("Retrying MSSQL connection: attempt=%d/%d, delay=%v", attempt+1, retryAttempts, delay)
}
select {
case <-time.After(delay):
case <-ctx.Done():
return ctx.Err()
}
}
// Open database connection
db, err = sql.Open("sqlserver", dsn)
if err != nil {
lastErr = err
if cfg.GetEnableLogging() {
logger.Warn("Failed to open MSSQL connection", "error", err)
}
continue
}
// Test the connection with context timeout
connectCtx, cancel := context.WithTimeout(ctx, cfg.GetConnectTimeout())
err = db.PingContext(connectCtx)
cancel()
if err != nil {
lastErr = err
db.Close()
if cfg.GetEnableLogging() {
logger.Warn("Failed to ping MSSQL database", "error", err)
}
continue
}
// Connection successful
break
}
if err != nil {
return fmt.Errorf("failed to connect after %d attempts: %w", retryAttempts, lastErr)
}
// Configure connection pool
if cfg.GetMaxOpenConns() != nil {
db.SetMaxOpenConns(*cfg.GetMaxOpenConns())
}
if cfg.GetMaxIdleConns() != nil {
db.SetMaxIdleConns(*cfg.GetMaxIdleConns())
}
if cfg.GetConnMaxLifetime() != nil {
db.SetConnMaxLifetime(*cfg.GetConnMaxLifetime())
}
if cfg.GetConnMaxIdleTime() != nil {
db.SetConnMaxIdleTime(*cfg.GetConnMaxIdleTime())
}
p.db = db
p.config = cfg
if cfg.GetEnableLogging() {
logger.Info("MSSQL connection established: name=%s, host=%s, database=%s", cfg.GetName(), cfg.GetHost(), cfg.GetDatabase())
}
return nil
}
// Close closes the MSSQL connection
func (p *MSSQLProvider) Close() error {
if p.db == nil {
return nil
}
err := p.db.Close()
if err != nil {
return fmt.Errorf("failed to close MSSQL connection: %w", err)
}
if p.config.GetEnableLogging() {
logger.Info("MSSQL connection closed: name=%s", p.config.GetName())
}
p.db = nil
return nil
}
// HealthCheck verifies the MSSQL connection is alive
func (p *MSSQLProvider) HealthCheck(ctx context.Context) error {
if p.db == nil {
return fmt.Errorf("database connection is nil")
}
// Use a short timeout for health checks
healthCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := p.db.PingContext(healthCtx); err != nil {
return fmt.Errorf("health check failed: %w", err)
}
return nil
}
// GetNative returns the native *sql.DB connection
func (p *MSSQLProvider) GetNative() (*sql.DB, error) {
if p.db == nil {
return nil, fmt.Errorf("database connection is not initialized")
}
return p.db, nil
}
// GetMongo returns an error for MSSQL (not a MongoDB connection)
func (p *MSSQLProvider) GetMongo() (*mongo.Client, error) {
return nil, ErrNotMongoDB
}
// Stats returns connection pool statistics
func (p *MSSQLProvider) Stats() *ConnectionStats {
if p.db == nil {
return &ConnectionStats{
Name: p.config.GetName(),
Type: "mssql",
Connected: false,
}
}
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,
}
}

View File

@@ -0,0 +1,231 @@
package providers
import (
"context"
"database/sql"
"fmt"
"math"
"sync"
"time"
_ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver
"go.mongodb.org/mongo-driver/mongo"
"github.com/bitechdev/ResolveSpec/pkg/logger"
)
// PostgresProvider implements Provider for PostgreSQL databases
type PostgresProvider struct {
db *sql.DB
config ConnectionConfig
listener *PostgresListener
mu sync.Mutex
}
// NewPostgresProvider creates a new PostgreSQL provider
func NewPostgresProvider() *PostgresProvider {
return &PostgresProvider{}
}
// Connect establishes a PostgreSQL connection
func (p *PostgresProvider) Connect(ctx context.Context, cfg ConnectionConfig) error {
// Build DSN
dsn, err := cfg.BuildDSN()
if err != nil {
return fmt.Errorf("failed to build DSN: %w", err)
}
// Connect with retry logic
var db *sql.DB
var lastErr error
retryAttempts := 3 // Default retry attempts
retryDelay := 1 * time.Second
for attempt := 0; attempt < retryAttempts; attempt++ {
if attempt > 0 {
delay := calculateBackoff(attempt, retryDelay, 10*time.Second)
if cfg.GetEnableLogging() {
logger.Info("Retrying PostgreSQL connection: attempt=%d/%d, delay=%v", attempt+1, retryAttempts, delay)
}
select {
case <-time.After(delay):
case <-ctx.Done():
return ctx.Err()
}
}
// Open database connection
db, err = sql.Open("pgx", dsn)
if err != nil {
lastErr = err
if cfg.GetEnableLogging() {
logger.Warn("Failed to open PostgreSQL connection", "error", err)
}
continue
}
// Test the connection with context timeout
connectCtx, cancel := context.WithTimeout(ctx, cfg.GetConnectTimeout())
err = db.PingContext(connectCtx)
cancel()
if err != nil {
lastErr = err
db.Close()
if cfg.GetEnableLogging() {
logger.Warn("Failed to ping PostgreSQL database", "error", err)
}
continue
}
// Connection successful
break
}
if err != nil {
return fmt.Errorf("failed to connect after %d attempts: %w", retryAttempts, lastErr)
}
// Configure connection pool
if cfg.GetMaxOpenConns() != nil {
db.SetMaxOpenConns(*cfg.GetMaxOpenConns())
}
if cfg.GetMaxIdleConns() != nil {
db.SetMaxIdleConns(*cfg.GetMaxIdleConns())
}
if cfg.GetConnMaxLifetime() != nil {
db.SetConnMaxLifetime(*cfg.GetConnMaxLifetime())
}
if cfg.GetConnMaxIdleTime() != nil {
db.SetConnMaxIdleTime(*cfg.GetConnMaxIdleTime())
}
p.db = db
p.config = cfg
if cfg.GetEnableLogging() {
logger.Info("PostgreSQL connection established: name=%s, host=%s, database=%s", cfg.GetName(), cfg.GetHost(), cfg.GetDatabase())
}
return nil
}
// Close closes the PostgreSQL connection
func (p *PostgresProvider) Close() error {
// Close listener if it exists
p.mu.Lock()
if p.listener != nil {
if err := p.listener.Close(); err != nil {
p.mu.Unlock()
return fmt.Errorf("failed to close listener: %w", err)
}
p.listener = nil
}
p.mu.Unlock()
if p.db == nil {
return nil
}
err := p.db.Close()
if err != nil {
return fmt.Errorf("failed to close PostgreSQL connection: %w", err)
}
if p.config.GetEnableLogging() {
logger.Info("PostgreSQL connection closed: name=%s", p.config.GetName())
}
p.db = nil
return nil
}
// HealthCheck verifies the PostgreSQL connection is alive
func (p *PostgresProvider) HealthCheck(ctx context.Context) error {
if p.db == nil {
return fmt.Errorf("database connection is nil")
}
// Use a short timeout for health checks
healthCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := p.db.PingContext(healthCtx); err != nil {
return fmt.Errorf("health check failed: %w", err)
}
return nil
}
// GetNative returns the native *sql.DB connection
func (p *PostgresProvider) GetNative() (*sql.DB, error) {
if p.db == nil {
return nil, fmt.Errorf("database connection is not initialized")
}
return p.db, nil
}
// GetMongo returns an error for PostgreSQL (not a MongoDB connection)
func (p *PostgresProvider) GetMongo() (*mongo.Client, error) {
return nil, ErrNotMongoDB
}
// Stats returns connection pool statistics
func (p *PostgresProvider) Stats() *ConnectionStats {
if p.db == nil {
return &ConnectionStats{
Name: p.config.GetName(),
Type: "postgres",
Connected: false,
}
}
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,
}
}
// GetListener returns a PostgreSQL listener for NOTIFY/LISTEN functionality
// The listener is lazily initialized on first call and reused for subsequent calls
func (p *PostgresProvider) GetListener(ctx context.Context) (*PostgresListener, error) {
p.mu.Lock()
defer p.mu.Unlock()
// Return existing listener if already created
if p.listener != nil {
return p.listener, nil
}
// Create new listener
listener := NewPostgresListener(p.config)
// Connect the listener
if err := listener.Connect(ctx); err != nil {
return nil, fmt.Errorf("failed to connect listener: %w", err)
}
p.listener = listener
return p.listener, nil
}
// calculateBackoff calculates exponential backoff delay
func calculateBackoff(attempt int, initial, maxDelay time.Duration) time.Duration {
delay := initial * time.Duration(math.Pow(2, float64(attempt)))
if delay > maxDelay {
delay = maxDelay
}
return delay
}

View File

@@ -0,0 +1,401 @@
package providers
import (
"context"
"fmt"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/bitechdev/ResolveSpec/pkg/logger"
)
// NotificationHandler is called when a notification is received
type NotificationHandler func(channel string, payload string)
// PostgresListener manages PostgreSQL LISTEN/NOTIFY functionality
type PostgresListener struct {
config ConnectionConfig
conn *pgx.Conn
// Channel subscriptions
channels map[string]NotificationHandler
mu sync.RWMutex
// Lifecycle management
ctx context.Context
cancel context.CancelFunc
closed bool
closeMu sync.Mutex
reconnectC chan struct{}
}
// NewPostgresListener creates a new PostgreSQL listener
func NewPostgresListener(cfg ConnectionConfig) *PostgresListener {
ctx, cancel := context.WithCancel(context.Background())
return &PostgresListener{
config: cfg,
channels: make(map[string]NotificationHandler),
ctx: ctx,
cancel: cancel,
reconnectC: make(chan struct{}, 1),
}
}
// Connect establishes a dedicated connection for listening
func (l *PostgresListener) Connect(ctx context.Context) error {
dsn, err := l.config.BuildDSN()
if err != nil {
return fmt.Errorf("failed to build DSN: %w", err)
}
// Parse connection config
connConfig, err := pgx.ParseConfig(dsn)
if err != nil {
return fmt.Errorf("failed to parse connection config: %w", err)
}
// Connect with retry logic
var conn *pgx.Conn
var lastErr error
retryAttempts := 3
retryDelay := 1 * time.Second
for attempt := 0; attempt < retryAttempts; attempt++ {
if attempt > 0 {
delay := calculateBackoff(attempt, retryDelay, 10*time.Second)
if l.config.GetEnableLogging() {
logger.Info("Retrying PostgreSQL listener connection: attempt=%d/%d, delay=%v", attempt+1, retryAttempts, delay)
}
select {
case <-time.After(delay):
case <-ctx.Done():
return ctx.Err()
}
}
conn, err = pgx.ConnectConfig(ctx, connConfig)
if err != nil {
lastErr = err
if l.config.GetEnableLogging() {
logger.Warn("Failed to connect PostgreSQL listener", "error", err)
}
continue
}
// Test the connection
if err = conn.Ping(ctx); err != nil {
lastErr = err
conn.Close(ctx)
if l.config.GetEnableLogging() {
logger.Warn("Failed to ping PostgreSQL listener", "error", err)
}
continue
}
// Connection successful
break
}
if err != nil {
return fmt.Errorf("failed to connect listener after %d attempts: %w", retryAttempts, lastErr)
}
l.mu.Lock()
l.conn = conn
l.mu.Unlock()
// Start notification handler
go l.handleNotifications()
// Start reconnection handler
go l.handleReconnection()
if l.config.GetEnableLogging() {
logger.Info("PostgreSQL listener connected: name=%s", l.config.GetName())
}
return nil
}
// Listen subscribes to a PostgreSQL notification channel
func (l *PostgresListener) Listen(channel string, handler NotificationHandler) error {
l.closeMu.Lock()
if l.closed {
l.closeMu.Unlock()
return fmt.Errorf("listener is closed")
}
l.closeMu.Unlock()
l.mu.Lock()
defer l.mu.Unlock()
if l.conn == nil {
return fmt.Errorf("listener connection is not initialized")
}
// Execute LISTEN command
_, err := l.conn.Exec(l.ctx, fmt.Sprintf("LISTEN %s", pgx.Identifier{channel}.Sanitize()))
if err != nil {
return fmt.Errorf("failed to listen on channel %s: %w", channel, err)
}
// Store the handler
l.channels[channel] = handler
if l.config.GetEnableLogging() {
logger.Info("Listening on channel: name=%s, channel=%s", l.config.GetName(), channel)
}
return nil
}
// Unlisten unsubscribes from a PostgreSQL notification channel
func (l *PostgresListener) Unlisten(channel string) error {
l.closeMu.Lock()
if l.closed {
l.closeMu.Unlock()
return fmt.Errorf("listener is closed")
}
l.closeMu.Unlock()
l.mu.Lock()
defer l.mu.Unlock()
if l.conn == nil {
return fmt.Errorf("listener connection is not initialized")
}
// Execute UNLISTEN command
_, err := l.conn.Exec(l.ctx, fmt.Sprintf("UNLISTEN %s", pgx.Identifier{channel}.Sanitize()))
if err != nil {
return fmt.Errorf("failed to unlisten from channel %s: %w", channel, err)
}
// Remove the handler
delete(l.channels, channel)
if l.config.GetEnableLogging() {
logger.Info("Unlistened from channel: name=%s, channel=%s", l.config.GetName(), channel)
}
return nil
}
// Notify sends a notification to a PostgreSQL channel
func (l *PostgresListener) Notify(ctx context.Context, channel string, payload string) error {
l.closeMu.Lock()
if l.closed {
l.closeMu.Unlock()
return fmt.Errorf("listener is closed")
}
l.closeMu.Unlock()
l.mu.RLock()
conn := l.conn
l.mu.RUnlock()
if conn == nil {
return fmt.Errorf("listener connection is not initialized")
}
// Execute NOTIFY command
_, err := conn.Exec(ctx, "SELECT pg_notify($1, $2)", channel, payload)
if err != nil {
return fmt.Errorf("failed to notify channel %s: %w", channel, err)
}
return nil
}
// Close closes the listener and all subscriptions
func (l *PostgresListener) Close() error {
l.closeMu.Lock()
if l.closed {
l.closeMu.Unlock()
return nil
}
l.closed = true
l.closeMu.Unlock()
// Cancel context to stop background goroutines
l.cancel()
l.mu.Lock()
defer l.mu.Unlock()
if l.conn == nil {
return nil
}
// Unlisten from all channels
for channel := range l.channels {
_, _ = l.conn.Exec(context.Background(), fmt.Sprintf("UNLISTEN %s", pgx.Identifier{channel}.Sanitize()))
}
// Close connection
err := l.conn.Close(context.Background())
if err != nil {
return fmt.Errorf("failed to close listener connection: %w", err)
}
l.conn = nil
l.channels = make(map[string]NotificationHandler)
if l.config.GetEnableLogging() {
logger.Info("PostgreSQL listener closed: name=%s", l.config.GetName())
}
return nil
}
// handleNotifications processes incoming notifications
func (l *PostgresListener) handleNotifications() {
for {
select {
case <-l.ctx.Done():
return
default:
}
l.mu.RLock()
conn := l.conn
l.mu.RUnlock()
if conn == nil {
// Connection not available, wait for reconnection
time.Sleep(100 * time.Millisecond)
continue
}
// Wait for notification with timeout
ctx, cancel := context.WithTimeout(l.ctx, 5*time.Second)
notification, err := conn.WaitForNotification(ctx)
cancel()
if err != nil {
// Check if context was cancelled
if l.ctx.Err() != nil {
return
}
// Check if it's a connection error
if pgconn.Timeout(err) {
// Timeout is normal, continue waiting
continue
}
// Connection error, trigger reconnection
if l.config.GetEnableLogging() {
logger.Warn("Notification error, triggering reconnection", "error", err)
}
select {
case l.reconnectC <- struct{}{}:
default:
}
time.Sleep(1 * time.Second)
continue
}
// Process notification
l.mu.RLock()
handler, exists := l.channels[notification.Channel]
l.mu.RUnlock()
if exists && handler != nil {
// Call handler in a goroutine to avoid blocking
go func(ch, payload string) {
defer func() {
if r := recover(); r != nil {
if l.config.GetEnableLogging() {
logger.Error("Notification handler panic: channel=%s, error=%v", ch, r)
}
}
}()
handler(ch, payload)
}(notification.Channel, notification.Payload)
}
}
}
// handleReconnection manages automatic reconnection
func (l *PostgresListener) handleReconnection() {
for {
select {
case <-l.ctx.Done():
return
case <-l.reconnectC:
if l.config.GetEnableLogging() {
logger.Info("Attempting to reconnect listener: name=%s", l.config.GetName())
}
// Close existing connection
l.mu.Lock()
if l.conn != nil {
l.conn.Close(context.Background())
l.conn = nil
}
// Save current subscriptions
channels := make(map[string]NotificationHandler)
for ch, handler := range l.channels {
channels[ch] = handler
}
l.mu.Unlock()
// Attempt reconnection
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
err := l.Connect(ctx)
cancel()
if err != nil {
if l.config.GetEnableLogging() {
logger.Error("Failed to reconnect listener: name=%s, error=%v", l.config.GetName(), err)
}
// Retry after delay
time.Sleep(5 * time.Second)
select {
case l.reconnectC <- struct{}{}:
default:
}
continue
}
// Resubscribe to all channels
for channel, handler := range channels {
if err := l.Listen(channel, handler); err != nil {
if l.config.GetEnableLogging() {
logger.Error("Failed to resubscribe to channel: name=%s, channel=%s, error=%v", l.config.GetName(), channel, err)
}
}
}
if l.config.GetEnableLogging() {
logger.Info("Listener reconnected successfully: name=%s", l.config.GetName())
}
}
}
}
// IsConnected returns true if the listener is connected
func (l *PostgresListener) IsConnected() bool {
l.mu.RLock()
defer l.mu.RUnlock()
return l.conn != nil
}
// Channels returns the list of channels currently being listened to
func (l *PostgresListener) Channels() []string {
l.mu.RLock()
defer l.mu.RUnlock()
channels := make([]string, 0, len(l.channels))
for ch := range l.channels {
channels = append(channels, ch)
}
return channels
}

View File

@@ -0,0 +1,228 @@
package providers_test
import (
"context"
"fmt"
"time"
"github.com/bitechdev/ResolveSpec/pkg/dbmanager"
"github.com/bitechdev/ResolveSpec/pkg/dbmanager/providers"
)
// ExamplePostgresListener_basic demonstrates basic LISTEN/NOTIFY usage
func ExamplePostgresListener_basic() {
// Create a connection config
cfg := &dbmanager.ConnectionConfig{
Name: "example",
Type: dbmanager.DatabaseTypePostgreSQL,
Host: "localhost",
Port: 5432,
User: "postgres",
Password: "password",
Database: "testdb",
ConnectTimeout: 10 * time.Second,
EnableLogging: true,
}
// Create and connect PostgreSQL provider
provider := providers.NewPostgresProvider()
ctx := context.Background()
if err := provider.Connect(ctx, cfg); err != nil {
panic(fmt.Sprintf("Failed to connect: %v", err))
}
defer provider.Close()
// Get listener
listener, err := provider.GetListener(ctx)
if err != nil {
panic(fmt.Sprintf("Failed to get listener: %v", err))
}
// Subscribe to a channel with a handler
err = listener.Listen("user_events", func(channel, payload string) {
fmt.Printf("Received notification on %s: %s\n", channel, payload)
})
if err != nil {
panic(fmt.Sprintf("Failed to listen: %v", err))
}
// Send a notification
err = listener.Notify(ctx, "user_events", `{"event":"user_created","user_id":123}`)
if err != nil {
panic(fmt.Sprintf("Failed to notify: %v", err))
}
// Wait for notification to be processed
time.Sleep(100 * time.Millisecond)
// Unsubscribe from the channel
if err := listener.Unlisten("user_events"); err != nil {
panic(fmt.Sprintf("Failed to unlisten: %v", err))
}
}
// ExamplePostgresListener_multipleChannels demonstrates listening to multiple channels
func ExamplePostgresListener_multipleChannels() {
cfg := &dbmanager.ConnectionConfig{
Name: "example",
Type: dbmanager.DatabaseTypePostgreSQL,
Host: "localhost",
Port: 5432,
User: "postgres",
Password: "password",
Database: "testdb",
ConnectTimeout: 10 * time.Second,
EnableLogging: false,
}
provider := providers.NewPostgresProvider()
ctx := context.Background()
if err := provider.Connect(ctx, cfg); err != nil {
panic(fmt.Sprintf("Failed to connect: %v", err))
}
defer provider.Close()
listener, err := provider.GetListener(ctx)
if err != nil {
panic(fmt.Sprintf("Failed to get listener: %v", err))
}
// Listen to multiple channels
channels := []string{"orders", "payments", "notifications"}
for _, ch := range channels {
channel := ch // Capture for closure
err := listener.Listen(channel, func(ch, payload string) {
fmt.Printf("[%s] %s\n", ch, payload)
})
if err != nil {
panic(fmt.Sprintf("Failed to listen on %s: %v", channel, err))
}
}
// Send notifications to different channels
listener.Notify(ctx, "orders", "New order #12345")
listener.Notify(ctx, "payments", "Payment received $99.99")
listener.Notify(ctx, "notifications", "Welcome email sent")
// Wait for notifications
time.Sleep(200 * time.Millisecond)
// Check active channels
activeChannels := listener.Channels()
fmt.Printf("Listening to %d channels: %v\n", len(activeChannels), activeChannels)
}
// ExamplePostgresListener_withDBManager demonstrates usage with DBManager
func ExamplePostgresListener_withDBManager() {
// This example shows how to use the listener with the full DBManager
// Assume we have a DBManager instance and get a connection
// conn, _ := dbMgr.Get("primary")
// Get the underlying provider (this would need to be exposed via the Connection interface)
// For now, this is a conceptual example
ctx := context.Background()
// Create provider directly for demonstration
cfg := &dbmanager.ConnectionConfig{
Name: "primary",
Type: dbmanager.DatabaseTypePostgreSQL,
Host: "localhost",
Port: 5432,
User: "postgres",
Password: "password",
Database: "myapp",
ConnectTimeout: 10 * time.Second,
}
provider := providers.NewPostgresProvider()
if err := provider.Connect(ctx, cfg); err != nil {
panic(err)
}
defer provider.Close()
// Get listener
listener, err := provider.GetListener(ctx)
if err != nil {
panic(err)
}
// Subscribe to application events
listener.Listen("cache_invalidation", func(channel, payload string) {
fmt.Printf("Cache invalidation request: %s\n", payload)
// Handle cache invalidation logic here
})
listener.Listen("config_reload", func(channel, payload string) {
fmt.Printf("Configuration reload request: %s\n", payload)
// Handle configuration reload logic here
})
// Simulate receiving notifications
listener.Notify(ctx, "cache_invalidation", "user:123")
listener.Notify(ctx, "config_reload", "database")
time.Sleep(100 * time.Millisecond)
}
// ExamplePostgresListener_errorHandling demonstrates error handling and reconnection
func ExamplePostgresListener_errorHandling() {
cfg := &dbmanager.ConnectionConfig{
Name: "example",
Type: dbmanager.DatabaseTypePostgreSQL,
Host: "localhost",
Port: 5432,
User: "postgres",
Password: "password",
Database: "testdb",
ConnectTimeout: 10 * time.Second,
EnableLogging: true,
}
provider := providers.NewPostgresProvider()
ctx := context.Background()
if err := provider.Connect(ctx, cfg); err != nil {
panic(fmt.Sprintf("Failed to connect: %v", err))
}
defer provider.Close()
listener, err := provider.GetListener(ctx)
if err != nil {
panic(fmt.Sprintf("Failed to get listener: %v", err))
}
// The listener automatically reconnects if the connection is lost
// Subscribe with error handling in the callback
err = listener.Listen("critical_events", func(channel, payload string) {
defer func() {
if r := recover(); r != nil {
fmt.Printf("Handler panic recovered: %v\n", r)
}
}()
// Process the event
fmt.Printf("Processing critical event: %s\n", payload)
// If processing fails, the panic will be caught by the defer above
// The listener will continue to function normally
})
if err != nil {
fmt.Printf("Failed to listen: %v\n", err)
return
}
// Check if listener is connected
if listener.IsConnected() {
fmt.Println("Listener is connected and ready")
}
// Send a notification
listener.Notify(ctx, "critical_events", "system_alert")
time.Sleep(100 * time.Millisecond)
}

View File

@@ -0,0 +1,83 @@
package providers
import (
"context"
"database/sql"
"errors"
"time"
"go.mongodb.org/mongo-driver/mongo"
)
// Common errors
var (
// ErrNotSQLDatabase is returned when attempting SQL operations on a non-SQL database
ErrNotSQLDatabase = errors.New("not a SQL database")
// ErrNotMongoDB is returned when attempting MongoDB operations on a non-MongoDB connection
ErrNotMongoDB = errors.New("not a MongoDB connection")
)
// ConnectionStats contains statistics about a database connection
type ConnectionStats struct {
Name string
Type string // Database type as string to avoid circular dependency
Connected bool
LastHealthCheck time.Time
HealthCheckStatus string
// SQL connection pool stats
OpenConnections int
InUse int
Idle int
WaitCount int64
WaitDuration time.Duration
MaxIdleClosed int64
MaxLifetimeClosed int64
}
// ConnectionConfig is a minimal interface for configuration
// The actual implementation is in dbmanager package
type ConnectionConfig interface {
BuildDSN() (string, error)
GetName() string
GetType() string
GetHost() string
GetPort() int
GetUser() string
GetPassword() string
GetDatabase() string
GetFilePath() string
GetConnectTimeout() time.Duration
GetQueryTimeout() time.Duration
GetEnableLogging() bool
GetEnableMetrics() bool
GetMaxOpenConns() *int
GetMaxIdleConns() *int
GetConnMaxLifetime() *time.Duration
GetConnMaxIdleTime() *time.Duration
GetReadPreference() string
}
// Provider creates and manages the underlying database connection
type Provider interface {
// Connect establishes the database connection
Connect(ctx context.Context, cfg ConnectionConfig) error
// Close closes the connection
Close() error
// HealthCheck verifies the connection is alive
HealthCheck(ctx context.Context) error
// GetNative returns the native *sql.DB (SQL databases only)
// Returns an error for non-SQL databases
GetNative() (*sql.DB, error)
// GetMongo returns the MongoDB client (MongoDB only)
// Returns an error for non-MongoDB databases
GetMongo() (*mongo.Client, error)
// Stats returns connection statistics
Stats() *ConnectionStats
}

View File

@@ -0,0 +1,177 @@
package providers
import (
"context"
"database/sql"
"fmt"
"time"
_ "github.com/glebarez/sqlite" // Pure Go SQLite driver
"go.mongodb.org/mongo-driver/mongo"
"github.com/bitechdev/ResolveSpec/pkg/logger"
)
// SQLiteProvider implements Provider for SQLite databases
type SQLiteProvider struct {
db *sql.DB
config ConnectionConfig
}
// NewSQLiteProvider creates a new SQLite provider
func NewSQLiteProvider() *SQLiteProvider {
return &SQLiteProvider{}
}
// Connect establishes a SQLite connection
func (p *SQLiteProvider) Connect(ctx context.Context, cfg ConnectionConfig) error {
// Build DSN
dsn, err := cfg.BuildDSN()
if err != nil {
return fmt.Errorf("failed to build DSN: %w", err)
}
// Open database connection
db, err := sql.Open("sqlite", dsn)
if err != nil {
return fmt.Errorf("failed to open SQLite connection: %w", err)
}
// Test the connection with context timeout
connectCtx, cancel := context.WithTimeout(ctx, cfg.GetConnectTimeout())
err = db.PingContext(connectCtx)
cancel()
if err != nil {
db.Close()
return fmt.Errorf("failed to ping SQLite database: %w", err)
}
// Configure connection pool
// Note: SQLite works best with MaxOpenConns=1 for write operations
// but can handle multiple readers
if cfg.GetMaxOpenConns() != nil {
db.SetMaxOpenConns(*cfg.GetMaxOpenConns())
} else {
// Default to 1 for SQLite to avoid "database is locked" errors
db.SetMaxOpenConns(1)
}
if cfg.GetMaxIdleConns() != nil {
db.SetMaxIdleConns(*cfg.GetMaxIdleConns())
}
if cfg.GetConnMaxLifetime() != nil {
db.SetConnMaxLifetime(*cfg.GetConnMaxLifetime())
}
if cfg.GetConnMaxIdleTime() != nil {
db.SetConnMaxIdleTime(*cfg.GetConnMaxIdleTime())
}
// Enable WAL mode for better concurrent access
_, err = db.ExecContext(ctx, "PRAGMA journal_mode=WAL")
if err != nil {
if cfg.GetEnableLogging() {
logger.Warn("Failed to enable WAL mode for SQLite", "error", err)
}
// Don't fail connection if WAL mode cannot be enabled
}
// Set busy timeout to handle locked database
_, err = db.ExecContext(ctx, "PRAGMA busy_timeout=5000")
if err != nil {
if cfg.GetEnableLogging() {
logger.Warn("Failed to set busy timeout for SQLite", "error", err)
}
}
p.db = db
p.config = cfg
if cfg.GetEnableLogging() {
logger.Info("SQLite connection established: name=%s, filepath=%s", cfg.GetName(), cfg.GetFilePath())
}
return nil
}
// Close closes the SQLite connection
func (p *SQLiteProvider) Close() error {
if p.db == nil {
return nil
}
err := p.db.Close()
if err != nil {
return fmt.Errorf("failed to close SQLite connection: %w", err)
}
if p.config.GetEnableLogging() {
logger.Info("SQLite connection closed: name=%s", p.config.GetName())
}
p.db = nil
return nil
}
// HealthCheck verifies the SQLite connection is alive
func (p *SQLiteProvider) HealthCheck(ctx context.Context) error {
if p.db == nil {
return fmt.Errorf("database connection is nil")
}
// Use a short timeout for health checks
healthCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// Execute a simple query to verify the database is accessible
var result int
err := p.db.QueryRowContext(healthCtx, "SELECT 1").Scan(&result)
if err != nil {
return fmt.Errorf("health check failed: %w", err)
}
if result != 1 {
return fmt.Errorf("health check returned unexpected result: %d", result)
}
return nil
}
// GetNative returns the native *sql.DB connection
func (p *SQLiteProvider) GetNative() (*sql.DB, error) {
if p.db == nil {
return nil, fmt.Errorf("database connection is not initialized")
}
return p.db, nil
}
// GetMongo returns an error for SQLite (not a MongoDB connection)
func (p *SQLiteProvider) GetMongo() (*mongo.Client, error) {
return nil, ErrNotMongoDB
}
// Stats returns connection pool statistics
func (p *SQLiteProvider) Stats() *ConnectionStats {
if p.db == nil {
return &ConnectionStats{
Name: p.config.GetName(),
Type: "sqlite",
Connected: false,
}
}
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,
}
}