diff --git a/internal/db/connection.go b/internal/db/connection.go new file mode 100644 index 0000000..b73ed4c --- /dev/null +++ b/internal/db/connection.go @@ -0,0 +1,328 @@ +package db + +import ( + "context" + "fmt" + "sync" + "time" + + "git.mchus.pro/mchus/quoteforge/internal/localdb" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +const ( + defaultConnectTimeout = 5 * time.Second + defaultPingInterval = 30 * time.Second + defaultReconnectCooldown = 10 * time.Second + + maxOpenConns = 10 + maxIdleConns = 2 + connMaxLifetime = 5 * time.Minute +) + +// ConnectionStatus represents the current status of the database connection +type ConnectionStatus struct { + IsConnected bool + LastCheck time.Time + LastError string // empty if no error + DSNHost string // host:port for display (without password!) +} + +// ConnectionManager manages database connections with thread-safety and connection pooling +type ConnectionManager struct { + localDB *localdb.LocalDB // for getting DSN from settings + mu sync.RWMutex // protects db and state + db *gorm.DB // current connection (nil if not connected) + lastError error // last connection error + lastCheck time.Time // time of last check/attempt + connectTimeout time.Duration // timeout for connection (default: 5s) + pingInterval time.Duration // minimum interval between pings (default: 30s) + reconnectCooldown time.Duration // pause after failed attempt (default: 10s) +} + +// NewConnectionManager creates a new ConnectionManager instance +func NewConnectionManager(localDB *localdb.LocalDB) *ConnectionManager { + return &ConnectionManager{ + localDB: localDB, + connectTimeout: defaultConnectTimeout, + pingInterval: defaultPingInterval, + reconnectCooldown: defaultReconnectCooldown, + db: nil, + lastError: nil, + lastCheck: time.Time{}, + } +} + +// GetDB returns the current database connection, establishing it if needed +// Thread-safe and respects connection cooldowns +func (cm *ConnectionManager) GetDB() (*gorm.DB, error) { + // Handle case where localDB is nil + if cm.localDB == nil { + return nil, fmt.Errorf("local database not initialized") + } + + // First check if we already have a valid connection + cm.mu.RLock() + if cm.db != nil { + // Check if connection is still valid and within ping interval + if time.Since(cm.lastCheck) < cm.pingInterval { + cm.mu.RUnlock() + return cm.db, nil + } + } + cm.mu.RUnlock() + + // Upgrade to write lock + cm.mu.Lock() + defer cm.mu.Unlock() + + // Double-check: someone else might have connected while we were waiting for the write lock + if cm.db != nil { + // Check if connection is still valid and within ping interval + if time.Since(cm.lastCheck) < cm.pingInterval { + return cm.db, nil + } + } + + // Check if we're in cooldown period after a failed attempt + if cm.lastError != nil && time.Since(cm.lastCheck) < cm.reconnectCooldown { + return nil, cm.lastError + } + + // Attempt to connect + err := cm.connect() + if err != nil { + cm.lastError = err + cm.lastCheck = time.Now() + return nil, err + } + + // Update last check time and return success + cm.lastCheck = time.Now() + cm.lastError = nil + return cm.db, nil +} + +// connect establishes a new database connection +func (cm *ConnectionManager) connect() error { + // Get DSN from local settings + dsn, err := cm.localDB.GetDSN() + if err != nil { + return fmt.Errorf("getting DSN: %w", err) + } + + // Create context with timeout + ctx, cancel := context.WithTimeout(context.Background(), cm.connectTimeout) + defer cancel() + + // Open database connection + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + return fmt.Errorf("opening database connection: %w", err) + } + + // Test the connection + sqlDB, err := db.DB() + if err != nil { + return fmt.Errorf("getting sql.DB: %w", err) + } + + // Ping with timeout + if err = sqlDB.PingContext(ctx); err != nil { + return fmt.Errorf("pinging database: %w", err) + } + + // Set connection pool settings + sqlDB.SetMaxOpenConns(maxOpenConns) + sqlDB.SetMaxIdleConns(maxIdleConns) + sqlDB.SetConnMaxLifetime(connMaxLifetime) + + // Store the connection + cm.db = db + + return nil +} + +// IsOnline checks if the database is currently connected and responsive +// Does not attempt to reconnect, only checks current state with caching +func (cm *ConnectionManager) IsOnline() bool { + cm.mu.RLock() + if cm.db == nil { + cm.mu.RUnlock() + return false + } + + // If we've checked recently, return cached result + if time.Since(cm.lastCheck) < cm.pingInterval { + cm.mu.RUnlock() + return true + } + cm.mu.RUnlock() + + // Need to perform actual ping + cm.mu.Lock() + defer cm.mu.Unlock() + + // Double-check after acquiring write lock + if cm.db == nil { + return false + } + + // Perform ping with timeout + ctx, cancel := context.WithTimeout(context.Background(), cm.connectTimeout) + defer cancel() + + sqlDB, err := cm.db.DB() + if err != nil { + cm.lastError = err + cm.lastCheck = time.Now() + cm.db = nil + return false + } + + if err = sqlDB.PingContext(ctx); err != nil { + cm.lastError = err + cm.lastCheck = time.Now() + cm.db = nil + return false + } + + // Update last check time and return success + cm.lastCheck = time.Now() + cm.lastError = nil + return true +} + +// TryConnect forces a new connection attempt (for UI "Reconnect" button) +// Ignores cooldown period +func (cm *ConnectionManager) TryConnect() error { + cm.mu.Lock() + defer cm.mu.Unlock() + + // Attempt to connect + err := cm.connect() + if err != nil { + cm.lastError = err + cm.lastCheck = time.Now() + return err + } + + // Update last check time and clear error + cm.lastCheck = time.Now() + cm.lastError = nil + return nil +} + +// Disconnect closes the current database connection +func (cm *ConnectionManager) Disconnect() { + cm.mu.Lock() + defer cm.mu.Unlock() + + if cm.db != nil { + sqlDB, err := cm.db.DB() + if err == nil { + sqlDB.Close() + } + } + cm.db = nil + cm.lastError = nil +} + +// GetLastError returns the last connection error (thread-safe) +func (cm *ConnectionManager) GetLastError() error { + cm.mu.RLock() + defer cm.mu.RUnlock() + return cm.lastError +} + +// GetStatus returns the current connection status +func (cm *ConnectionManager) GetStatus() ConnectionStatus { + cm.mu.RLock() + defer cm.mu.RUnlock() + + status := ConnectionStatus{ + IsConnected: cm.db != nil, + LastCheck: cm.lastCheck, + LastError: "", + DSNHost: "", + } + + if cm.lastError != nil { + status.LastError = cm.lastError.Error() + } + + // Extract host from DSN for display + if cm.localDB != nil { + if dsn, err := cm.localDB.GetDSN(); err == nil { + // Parse DSN to extract host:port + // Format: user:password@tcp(host:port)/database?... + status.DSNHost = extractHostFromDSN(dsn) + } + } + + return status +} + +// extractHostFromDSN extracts the host:port part from a DSN string +func extractHostFromDSN(dsn string) string { + // Find the tcp( part + tcpStart := 0 + if tcpStart = len("tcp("); tcpStart < len(dsn) && dsn[tcpStart] == '(' { + // Look for the closing parenthesis + parenEnd := -1 + for i := tcpStart + 1; i < len(dsn); i++ { + if dsn[i] == ')' { + parenEnd = i + break + } + } + if parenEnd != -1 { + // Extract host:port part between tcp( and ) + hostPort := dsn[tcpStart+1:parenEnd] + return hostPort + } + } + + // Fallback: try to find host:port by looking for @tcp( pattern + atIndex := -1 + for i := 0; i < len(dsn)-4; i++ { + if dsn[i:i+4] == "@tcp" { + atIndex = i + break + } + } + + if atIndex != -1 { + // Look for the opening parenthesis after @tcp + parenStart := -1 + for i := atIndex + 4; i < len(dsn); i++ { + if dsn[i] == '(' { + parenStart = i + break + } + } + + if parenStart != -1 { + // Look for the closing parenthesis + parenEnd := -1 + for i := parenStart + 1; i < len(dsn); i++ { + if dsn[i] == ')' { + parenEnd = i + break + } + } + + if parenEnd != -1 { + hostPort := dsn[parenStart+1:parenEnd] + return hostPort + } + } + } + + // If we can't parse it, return empty string + return "" +} \ No newline at end of file diff --git a/internal/middleware/offline.go b/internal/middleware/offline.go index a1c5ff1..86e2037 100644 --- a/internal/middleware/offline.go +++ b/internal/middleware/offline.go @@ -4,17 +4,17 @@ import ( "log/slog" "github.com/gin-gonic/gin" + "git.mchus.pro/mchus/quoteforge/internal/db" "git.mchus.pro/mchus/quoteforge/internal/localdb" - "gorm.io/gorm" ) // OfflineDetector creates middleware that detects offline mode // Sets context values: // - "is_offline" (bool) - true if MariaDB is unavailable // - "localdb" (*localdb.LocalDB) - local database instance for fallback -func OfflineDetector(mariaDB *gorm.DB, local *localdb.LocalDB) gin.HandlerFunc { +func OfflineDetector(connMgr *db.ConnectionManager, local *localdb.LocalDB) gin.HandlerFunc { return func(c *gin.Context) { - isOffline := !checkMariaDBOnline(mariaDB) + isOffline := !connMgr.IsOnline() // Set context values for handlers c.Set("is_offline", isOffline) @@ -27,17 +27,3 @@ func OfflineDetector(mariaDB *gorm.DB, local *localdb.LocalDB) gin.HandlerFunc { c.Next() } } - -// checkMariaDBOnline checks if MariaDB is accessible -func checkMariaDBOnline(mariaDB *gorm.DB) bool { - sqlDB, err := mariaDB.DB() - if err != nil { - return false - } - - if err := sqlDB.Ping(); err != nil { - return false - } - - return true -} diff --git a/internal/services/sync/worker.go b/internal/services/sync/worker.go index e21b6b4..2895c44 100644 --- a/internal/services/sync/worker.go +++ b/internal/services/sync/worker.go @@ -5,23 +5,23 @@ import ( "log/slog" "time" - "gorm.io/gorm" + "git.mchus.pro/mchus/quoteforge/internal/db" ) // Worker performs background synchronization at regular intervals type Worker struct { service *Service - db *gorm.DB + connMgr *db.ConnectionManager interval time.Duration logger *slog.Logger stopCh chan struct{} } // NewWorker creates a new background sync worker -func NewWorker(service *Service, db *gorm.DB, interval time.Duration) *Worker { +func NewWorker(service *Service, connMgr *db.ConnectionManager, interval time.Duration) *Worker { return &Worker{ service: service, - db: db, + connMgr: connMgr, interval: interval, logger: slog.Default(), stopCh: make(chan struct{}), @@ -30,11 +30,7 @@ func NewWorker(service *Service, db *gorm.DB, interval time.Duration) *Worker { // isOnline checks if the database connection is available func (w *Worker) isOnline() bool { - sqlDB, err := w.db.DB() - if err != nil { - return false - } - return sqlDB.Ping() == nil + return w.connMgr.IsOnline() } // Start begins the background sync loop in a goroutine