// Package database kapselt den Zugriff auf die PostgreSQL-Control-Plane-Datenbank. // // Diese Datenbank enthält Konfiguration, Jobs, Metadaten und Statistiken — // niemals Backup-Nutzdaten (SYNCOVA_DATABASE.md §1). Der Verlust der Datenbank // darf ein Repository nicht unbrauchbar machen (PROMPT.md §2.3). package database import ( "context" "fmt" "log/slog" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/syncova/syncova/packages/platform/config" "github.com/syncova/syncova/packages/platform/health" "github.com/syncova/syncova/packages/platform/logging" ) // Pool ist der Verbindungspool zur Control-Plane-Datenbank. type Pool struct { // connectionPool ist der zugrunde liegende pgx-Pool. connectionPool *pgxpool.Pool // logger protokolliert Verbindungsereignisse. logger *slog.Logger // redactedConnectionString beschreibt das Ziel ohne Passwort und darf geloggt werden. redactedConnectionString string } // Connect baut den Verbindungspool auf und prüft die Erreichbarkeit sofort. // // Ein Dienst startet nicht mit einer unbestätigten Datenbankverbindung: sonst // zeigte er Betriebsbereitschaft, ohne arbeiten zu können. func Connect(connectContext context.Context, databaseConfig config.DatabaseConfig, baseLogger *slog.Logger) (*Pool, error) { databaseLogger := logging.WithComponent(baseLogger, "database") poolConfig, parseError := pgxpool.ParseConfig(databaseConfig.ConnectionString()) if parseError != nil { // Der Fehlertext von pgx kann die DSN samt Passwort enthalten und wird // deshalb bewusst nicht weitergereicht. return nil, fmt.Errorf("die Datenbankkonfiguration ist ungültig (Ziel: %s)", databaseConfig.RedactedConnectionString()) } poolConfig.MaxConns = databaseConfig.MaxOpenConnections poolConfig.MaxConnIdleTime = databaseConfig.MaxConnectionIdleTime connectionPool, poolError := pgxpool.NewWithConfig(connectContext, poolConfig) if poolError != nil { return nil, fmt.Errorf("der Verbindungspool zur Datenbank konnte nicht erstellt werden (Ziel: %s): %w", databaseConfig.RedactedConnectionString(), poolError) } // Ein erster Ping belegt, dass Adresse, Zugangsdaten und TLS-Modus stimmen. pingContext, cancelPingContext := context.WithTimeout(connectContext, databaseConfig.ConnectTimeout) defer cancelPingContext() if pingError := connectionPool.Ping(pingContext); pingError != nil { connectionPool.Close() return nil, fmt.Errorf("die Datenbank ist nicht erreichbar (Ziel: %s): %w", databaseConfig.RedactedConnectionString(), pingError) } databaseLogger.Info("datenbankverbindung hergestellt", slog.String("target", databaseConfig.RedactedConnectionString()), slog.Int("max_connections", int(databaseConfig.MaxOpenConnections)), ) return &Pool{ connectionPool: connectionPool, logger: databaseLogger, redactedConnectionString: databaseConfig.RedactedConnectionString(), }, nil } // Connections gibt den zugrunde liegenden pgx-Pool für Abfragen frei. func (pool *Pool) Connections() *pgxpool.Pool { return pool.connectionPool } // Close schließt alle Verbindungen des Pools. func (pool *Pool) Close() { pool.connectionPool.Close() pool.logger.Info("datenbankverbindung geschlossen") } // HealthCheck liefert eine Prüffunktion für die Health Engine. // // Sie misst die Antwortzeit mit, weil eine langsame Datenbank die häufigste // Vorstufe eines echten Ausfalls ist (PROMPT.md §64). func (pool *Pool) HealthCheck() health.CheckFunc { return func(checkContext context.Context) health.CheckResult { if pingError := pool.connectionPool.Ping(checkContext); pingError != nil { return health.CheckResult{ Status: health.StatusOffline, Message: "Syncova kann die Control-Plane-Datenbank nicht erreichen.", RecommendedAction: "Erreichbarkeit von PostgreSQL sowie Zugangsdaten und TLS-Einstellungen prüfen.", } } poolStatistics := pool.connectionPool.Stat() // Sind alle Verbindungen belegt, warten neue Anfragen — ein Frühwarnzeichen. if poolStatistics.AcquiredConns() >= poolStatistics.MaxConns() { return health.CheckResult{ Status: health.StatusWarning, Message: "Alle Datenbankverbindungen sind belegt.", RecommendedAction: "Gleichzeitige Jobs verringern oder SYNCOVA_DB_MAX_OPEN_CONNECTIONS erhöhen.", } } return health.CheckResult{Status: health.StatusHealthy} } } // AwaitAvailable wartet, bis die Datenbank erreichbar ist oder die Frist abläuft. // // Beim gemeinsamen Start von Datenbank und Anwendung (etwa via docker compose) // ist PostgreSQL oft wenige Sekunden später bereit als der Dienst. func AwaitAvailable(waitContext context.Context, databaseConfig config.DatabaseConfig, baseLogger *slog.Logger, maximumWaitDuration time.Duration) (*Pool, error) { deadlineContext, cancelDeadlineContext := context.WithTimeout(waitContext, maximumWaitDuration) defer cancelDeadlineContext() // retryInterval ist bewusst kurz: der Start soll sich nicht unnötig verzögern. const retryInterval = time.Second var lastConnectError error for { connectionPool, connectError := Connect(deadlineContext, databaseConfig, baseLogger) if connectError == nil { return connectionPool, nil } lastConnectError = connectError select { case <-deadlineContext.Done(): return nil, fmt.Errorf("die Datenbank war innerhalb von %s nicht erreichbar: %w", maximumWaitDuration, lastConnectError) case <-time.After(retryInterval): } } }