package dbProcs; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.sql.Connection; import java.sql.SQLException; import java.util.Properties; import java.util.concurrent.ConcurrentHashMap; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; /** * Connection pool manager using HikariCP for efficient database connection management. * *

This class provides connection pooling for MySQL/MariaDB databases, significantly improving * performance by reusing connections instead of creating new ones for each request. * *

This file is part of the Security Shepherd Project. * *

The Security Shepherd project is free software: you can redistribute it and/or modify it under * the terms of the GNU General Public License as published by the Free Software Foundation, either * version 3 of the License, or (at your option) any later version. * * @author Paul */ public class ConnectionPool { private static final Logger log = LogManager.getLogger(ConnectionPool.class); // Singleton instance for the core database pool private static volatile HikariDataSource coreDataSource; // Lock object for thread-safe initialization private static final Object lock = new Object(); // Challenge-specific connection pools (keyed by schema name) private static final ConcurrentHashMap challengePools = new ConcurrentHashMap<>(); // Default pool configuration values (used by core pool) private static final int DEFAULT_MAX_POOL_SIZE = 20; private static final int DEFAULT_MIN_IDLE = 5; private static final long DEFAULT_CONNECTION_TIMEOUT = 5000; // 5 seconds — fail fast under overload private static final long DEFAULT_IDLE_TIMEOUT = 600000; // 10 minutes private static final long DEFAULT_MAX_LIFETIME = 1800000; // 30 minutes private static final long DEFAULT_LEAK_DETECTION_THRESHOLD = 60000; // 60 seconds // Challenge pool configuration values (smaller footprint per schema) private static final int CHALLENGE_MAX_POOL_SIZE = 3; private static final int CHALLENGE_MIN_IDLE = 0; private static final long CHALLENGE_IDLE_TIMEOUT = 120000; // 2 minutes // Flag to track if pool has been initialized private static volatile boolean initialized = false; /** Private constructor to prevent instantiation */ private ConnectionPool() {} /** * Initialize the connection pool. This method is idempotent - calling it multiple times has no * effect after the first successful initialization. */ public static void initialize() { if (!initialized) { synchronized (lock) { if (!initialized) { try { coreDataSource = createCoreDataSource(); initialized = true; log.info("Connection pool initialized successfully"); } catch (Exception e) { log.error("Failed to initialize connection pool: " + e.getMessage(), e); throw new RuntimeException("Failed to initialize connection pool", e); } } } } } /** * Creates the HikariDataSource for the core database. * * @return Configured HikariDataSource */ private static HikariDataSource createCoreDataSource() { Properties prop = loadDatabaseProperties(); String connectionURL = prop.getProperty("databaseConnectionURL"); String databaseSchema = prop.getProperty("databaseSchema"); String dbOptions = prop.getProperty("databaseOptions"); String username = prop.getProperty("databaseUsername"); String password = prop.getProperty("databasePassword"); // Build the full JDBC URL String jdbcUrl = connectionURL + databaseSchema; if (dbOptions != null && !dbOptions.isEmpty()) { jdbcUrl += "?" + dbOptions; } return createDataSource(jdbcUrl, username, password, prop, "CorePool"); } /** * Creates a HikariDataSource with the given configuration. * * @param jdbcUrl The JDBC URL * @param username Database username * @param password Database password * @param prop Properties containing pool configuration * @param poolName Name for the pool (for logging/monitoring) * @return Configured HikariDataSource */ private static HikariDataSource createDataSource( String jdbcUrl, String username, String password, Properties prop, String poolName) { HikariConfig config = new HikariConfig(); config.setJdbcUrl(jdbcUrl); config.setUsername(username); config.setPassword(password); config.setPoolName(poolName); String driverClassName = prop.getProperty("DriverType"); if (driverClassName == null || driverClassName.isEmpty()) { if (jdbcUrl.startsWith("jdbc:mariadb:")) { driverClassName = "org.mariadb.jdbc.Driver"; } else if (jdbcUrl.startsWith("jdbc:mysql:")) { driverClassName = "com.mysql.cj.jdbc.Driver"; } else { throw new IllegalArgumentException("Unsupported JDBC URL: " + jdbcUrl); } } config.setDriverClassName(driverClassName); // Pool size configuration config.setMaximumPoolSize(getIntProperty(prop, "pool.maximumPoolSize", DEFAULT_MAX_POOL_SIZE)); config.setMinimumIdle(getIntProperty(prop, "pool.minimumIdle", DEFAULT_MIN_IDLE)); // Timeout configuration config.setConnectionTimeout( getLongProperty(prop, "pool.connectionTimeout", DEFAULT_CONNECTION_TIMEOUT)); config.setIdleTimeout(getLongProperty(prop, "pool.idleTimeout", DEFAULT_IDLE_TIMEOUT)); config.setMaxLifetime(getLongProperty(prop, "pool.maxLifetime", DEFAULT_MAX_LIFETIME)); // Leak detection - logs a warning if a connection is held longer than this threshold config.setLeakDetectionThreshold( getLongProperty(prop, "pool.leakDetectionThreshold", DEFAULT_LEAK_DETECTION_THRESHOLD)); // Connection validation config.setConnectionTestQuery("SELECT 1"); // Performance optimizations config.addDataSourceProperty("cachePrepStmts", "true"); config.addDataSourceProperty("prepStmtCacheSize", "250"); config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); config.addDataSourceProperty("useServerPrepStmts", "true"); log.debug( "Creating HikariCP pool '{}' with maxPoolSize={}, minIdle={}", poolName, config.getMaximumPoolSize(), config.getMinimumIdle()); return new HikariDataSource(config); } /** * Creates a HikariDataSource with custom pool size settings. Used for challenge pools which need * a smaller resource footprint than the core pool. * * @param jdbcUrl The JDBC URL * @param username Database username * @param password Database password * @param prop Properties containing pool configuration * @param poolName Name for the pool (for logging/monitoring) * @param maxPoolSize Maximum number of connections in the pool * @param minIdle Minimum number of idle connections maintained * @param idleTimeout Idle timeout in milliseconds before a connection is retired * @return Configured HikariDataSource */ private static HikariDataSource createDataSource( String jdbcUrl, String username, String password, Properties prop, String poolName, int maxPoolSize, int minIdle, long idleTimeout) { HikariConfig config = new HikariConfig(); config.setJdbcUrl(jdbcUrl); config.setUsername(username); config.setPassword(password); config.setPoolName(poolName); String driverClassName = prop.getProperty("DriverType"); if (driverClassName == null || driverClassName.isEmpty()) { if (jdbcUrl.startsWith("jdbc:mariadb:")) { driverClassName = "org.mariadb.jdbc.Driver"; } else if (jdbcUrl.startsWith("jdbc:mysql:")) { driverClassName = "com.mysql.cj.jdbc.Driver"; } else { throw new IllegalArgumentException("Unsupported JDBC URL: " + jdbcUrl); } } config.setDriverClassName(driverClassName); // Pool size configuration (using provided values instead of defaults) config.setMaximumPoolSize(maxPoolSize); config.setMinimumIdle(minIdle); // Timeout configuration config.setConnectionTimeout( getLongProperty(prop, "pool.connectionTimeout", DEFAULT_CONNECTION_TIMEOUT)); config.setIdleTimeout(idleTimeout); config.setMaxLifetime(getLongProperty(prop, "pool.maxLifetime", DEFAULT_MAX_LIFETIME)); // Leak detection - logs a warning if a connection is held longer than this threshold config.setLeakDetectionThreshold( getLongProperty(prop, "pool.leakDetectionThreshold", DEFAULT_LEAK_DETECTION_THRESHOLD)); // Connection validation config.setConnectionTestQuery("SELECT 1"); // Performance optimizations config.addDataSourceProperty("cachePrepStmts", "true"); config.addDataSourceProperty("prepStmtCacheSize", "250"); config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); config.addDataSourceProperty("useServerPrepStmts", "true"); log.debug( "Creating HikariCP pool '{}' with maxPoolSize={}, minIdle={}, idleTimeout={}", poolName, config.getMaximumPoolSize(), config.getMinimumIdle(), config.getIdleTimeout()); return new HikariDataSource(config); } /** * Loads database properties from the configuration file. * * @return Properties object containing database configuration */ private static Properties loadDatabaseProperties() { Properties prop = new Properties(); String mysqlProps = Constants.MYSQL_DB_PROP; try (InputStream input = new FileInputStream(mysqlProps)) { prop.load(input); } catch (IOException e) { log.error("Could not load database properties file: " + e.getMessage(), e); throw new RuntimeException("Could not load database properties", e); } return prop; } /** * Loads database properties if the file exists; otherwise returns an empty Properties object. * Used by the challenge-connection path during first-time setup, when callers supply * url/user/password as arguments and the on-disk file does not yet exist. All pool tuning * properties have defaults, so an empty Properties object is safe. */ private static Properties loadDatabasePropertiesIfPresent() { if (!new File(Constants.MYSQL_DB_PROP).isFile()) { return new Properties(); } return loadDatabaseProperties(); } /** * Gets a connection from the core database pool. * * @return A connection from the pool * @throws SQLException if a connection cannot be obtained */ public static Connection getConnection() throws SQLException { if (!initialized) { try { initialize(); } catch (RuntimeException e) { throw new SQLException("Connection pool not available", e); } } return coreDataSource.getConnection(); } /** * Gets a connection for a specific challenge schema. * * @param connectionURL The base connection URL * @param challengeConnectionURL The challenge-specific part of the URL * @param dbOptions Database options * @param username Database username * @param password Database password * @return A connection from the challenge-specific pool * @throws SQLException if a connection cannot be obtained */ public static Connection getChallengeConnection( String connectionURL, String challengeConnectionURL, String dbOptions, String username, String password) throws SQLException { // Build the full JDBC URL final String jdbcUrl; if (dbOptions != null && !dbOptions.isEmpty()) { jdbcUrl = connectionURL + challengeConnectionURL + "?" + dbOptions; } else { jdbcUrl = connectionURL + challengeConnectionURL; } // Use the full URL and username as the pool key String poolKey = jdbcUrl + ":" + username; HikariDataSource dataSource = challengePools.computeIfAbsent( poolKey, key -> { // Tolerate missing database.properties: during first-time setup the file does // not exist yet, but the caller already supplied url/user/password. Pool tuning // falls back to defaults when properties are absent. Properties prop = loadDatabasePropertiesIfPresent(); return createDataSource( jdbcUrl, username, password, prop, "ChallengePool-" + username, CHALLENGE_MAX_POOL_SIZE, CHALLENGE_MIN_IDLE, CHALLENGE_IDLE_TIMEOUT); }); return dataSource.getConnection(); } /** * Shuts down all connection pools. This should be called when the application is shutting down. */ public static void shutdown() { synchronized (lock) { log.info("Shutting down connection pools..."); // Close core pool if (coreDataSource != null && !coreDataSource.isClosed()) { coreDataSource.close(); log.debug("Core connection pool closed"); } // Close all challenge pools for (HikariDataSource ds : challengePools.values()) { if (!ds.isClosed()) { ds.close(); } } challengePools.clear(); coreDataSource = null; initialized = false; log.info("All connection pools shut down successfully"); } } /** * Checks if the connection pool has been initialized. * * @return true if initialized, false otherwise */ public static boolean isInitialized() { return initialized; } /** * Gets pool statistics for monitoring (useful for debugging). * * @return String containing pool statistics */ public static String getPoolStats() { if (coreDataSource == null) { return "Pool not initialized"; } return String.format( "Pool: %s, Active: %d, Idle: %d, Total: %d, Waiting: %d", coreDataSource.getPoolName(), coreDataSource.getHikariPoolMXBean().getActiveConnections(), coreDataSource.getHikariPoolMXBean().getIdleConnections(), coreDataSource.getHikariPoolMXBean().getTotalConnections(), coreDataSource.getHikariPoolMXBean().getThreadsAwaitingConnection()); } /** * Returns the number of connections currently checked out from the core pool. Intended for * integration tests that verify connections are returned after Getter/Setter operations. * * @return active connection count, or -1 if the pool is not initialized */ public static int getCoreActiveConnections() { if (coreDataSource == null || coreDataSource.isClosed()) { return -1; } return coreDataSource.getHikariPoolMXBean().getActiveConnections(); } /** Helper method to get an integer property with a default value. */ private static int getIntProperty(Properties prop, String key, int defaultValue) { String value = prop.getProperty(key); if (value != null) { try { return Integer.parseInt(value); } catch (NumberFormatException e) { log.warn( "Invalid integer value for property '{}': {}, using default: {}", key, value, defaultValue); } } return defaultValue; } /** Helper method to get a long property with a default value. */ private static long getLongProperty(Properties prop, String key, long defaultValue) { String value = prop.getProperty(key); if (value != null) { try { return Long.parseLong(value); } catch (NumberFormatException e) { log.warn( "Invalid long value for property '{}': {}, using default: {}", key, value, defaultValue); } } return defaultValue; } /** Resets the pool state. This is primarily for testing purposes. */ public static void reset() { shutdown(); } /** * Returns the number of challenge pools currently active. Intended for testing and monitoring. * * @return the number of challenge pools */ public static int getChallengePoolCount() { return challengePools.size(); } /** * Returns the HikariDataSource for a challenge pool by key, or null if not found. Intended for * testing to verify pool configuration. * * @param poolKey the pool key (jdbcUrl:username) * @return the HikariDataSource, or null */ static HikariDataSource getChallengePool(String poolKey) { return challengePools.get(poolKey); } }