JdbcDataSource.java

/*
** Module   : JdbcDataSource.java
** Abstract : JDBC data source which wraps a C3P0 pooled data source
**
** Copyright (c) 2019-2025, Golden Code Development Corporation.
**
** -#- -I- --Date-- --------------------------------Description----------------------------------
** 001 ECF 20190902 Created initial version. Establishes a C3P0 pooled data source per database.
** 002 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
** 003 RAA 20240423 Modified methods to also include the database configuration.
**     RAA 20240426 The DataSource is now also based on the tenant's id, not only the database.
** 004 OM  20240909 Improved Database API.
** 005 OM  20241128 Multi-tenant runtime support: selected the proper persistence context, eventually
**                  based on [sharedDb] parameter.
** 006 AS  20241209 Implemented a retry system for getting connections.
** 007 LS  20250321 Remove the dataSource from the sources and close it when it becomes broken.
*/

/*
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU Affero General Public License as
** published by the Free Software Foundation, either version 3 of the
** License, or (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
** GNU Affero General Public License for more details.
**
** You may find a copy of the GNU Affero GPL version 3 at the following
** location: https://www.gnu.org/licenses/agpl-3.0.en.html
** 
** Additional terms under GNU Affero GPL version 3 section 7:
** 
**   Under Section 7 of the GNU Affero GPL version 3, the following additional
**   terms apply to the works covered under the License.  These additional terms
**   are non-permissive additional terms allowed under Section 7 of the GNU
**   Affero GPL version 3 and may not be removed by you.
** 
**   0. Attribution Requirement.
** 
**     You must preserve all legal notices or author attributions in the covered
**     work or Appropriate Legal Notices displayed by works containing the covered
**     work.  You may not remove from the covered work any author or developer
**     credit already included within the covered work.
** 
**   1. No License To Use Trademarks.
** 
**     This license does not grant any license or rights to use the trademarks
**     Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
**     of Golden Code Development Corporation. You are not authorized to use the
**     name Golden Code, FWD, or the names of any author or contributor, for
**     publicity purposes without written authorization.
** 
**   2. No Misrepresentation of Affiliation.
** 
**     You may not represent yourself as Golden Code Development Corporation or FWD.
** 
**     You may not represent yourself for publicity purposes as associated with
**     Golden Code Development Corporation, FWD, or any author or contributor to
**     the covered work, without written authorization.
** 
**   3. No Misrepresentation of Source or Origin.
** 
**     You may not represent the covered work as solely your work.  All modified
**     versions of the covered work must be marked in a reasonable way to make it
**     clear that the modified work is not originating from Golden Code Development
**     Corporation or FWD.  All modified versions must contain the notices of
**     attribution required in this license.
*/

package com.goldencode.p2j.persist.orm;

import java.sql.*;
import java.util.*;
import java.util.concurrent.*;
import javax.sql.DataSource;
import com.goldencode.p2j.persist.*;
import com.goldencode.p2j.util.logging.*;
import com.mchange.v2.c3p0.ComboPooledDataSource;
import com.mchange.v2.resourcepool.ResourcePoolException;

/**
 * A JDBC data source which wraps a C3P0 pooled data source. Access to pooled data sources is via
 * static methods, and is organized by database.
 */
class JdbcDataSource
{
   /** Logger */
   static final CentralLogger log = CentralLogger.get(JdbcDataSource.class.getName());
   
   /** Concurrent map of instances, keyed by database keys. */
   private static Map<DatabaseKey, JdbcDataSource> sources = new ConcurrentHashMap<>();
   
   /** Delegate data source */
   private final DataSource dataSource;
   
   /** Flag indicating whether data source supports batch operations */
   private final boolean batch;
   
   /**
    * Get a JDBC connection from the pool, creating it if necessary.
    * 
    * @param   database
    *          Database with which the connection must be established.
    * @param   cfg
    *          The configuration of the given database.
    * @param   tenantName
    *          The name of the tenant.
    * 
    * @return  JDBC connection.
    * 
    * @throws  PersistenceException
    *          if there is an error configuring or obtaining the data source or connection.
    */
   static Connection getConnection(Database database, DatabaseConfig cfg, String tenantName)
   throws PersistenceException
   {
      Connection connection = null;
      Exception e = null;
      int retryConnectionCount = 0;
      boolean retry = true;

      while (retry)
      {
         retry = false;
         try
         {
            DataSource ds = getDataSource(database, cfg, tenantName).dataSource;

            // There is a possibility that another thread will throw an
            // InterruptedException at the same time a checkout of a
            // connection is attempted by this thread and it will fail, in this cases,
            // the checkout is retried.
            // It is highly unlikely that all 3 attempts will be interrupted by another thread,
            // but if that happens, we will need to adjust the retryConnectionCount.
            while (connection == null && retryConnectionCount < 3)
            {
               try
               {
                  connection = ds.getConnection();
               }
               catch (SQLException exc)
               {
                  e = exc;
                  Throwable cause = exc.getCause();

                  // check if the underlying cause of the exception is a broken resource pool
                  // the c3p0 pool can declare itself "broken" if it fails to obtain a connection and
                  // has the breakAfterAcquireFailure flag enabled.
                  if (cause instanceof ResourcePoolException &&
                      cause.getMessage().equals(DBUtils.BROKEN_POOL_ERROR))
                  {
                     DatabaseKey key = new DatabaseKey(database, tenantName);
                     if (ds instanceof ComboPooledDataSource)
                     {
                        // the broken datasource must be closed manually to release its resources.
                        ((ComboPooledDataSource) ds).close();
                     }
                     // the dataSource is broken, we need to remove it
                     sources.remove(key);

                     // we detected now that the datasource is broken,
                     // but maybe the connection was restored in the meantime
                     // try getting a new one before throwing the PersistenceException
                     retry = true;
                     break;
                  }
                  retryConnectionCount++;
               }
            }
         }
         catch (PersistenceException exc)
         {
            e = exc;
         }
      }
      if (connection == null)
      {
         throw new PersistenceException(DBUtils.JDBC_CONNECTION_ERROR, e);
      }
      return connection;
   }
   
   /**
    * Indicate whether the data source represented by the given database object supports batch
    * operations.
    * 
    * @param   database
    *          Database for which batch support information is needed.
    * 
    * @return  {@code true} if batch operations are supported, else {@code false}.
    * 
    * @throws  PersistenceException
    *          if database metadata needs to be queried and there is an error doing so.
    */
   static boolean supportsBatch(Database database)
   throws PersistenceException
   {
      return getDataSource(database, null, null).batch;
   }
   
   /**
    * Get the JDBC data source for the given database.
    * 
    * @param   database
    *          Database object which identifies the data source.
    * @param   cfg
    *          The configuration of the given database.
    * @param   tenantName
    *          The name of the tenant.
    * 
    * @return  Data source object.
    * 
    * @throws  PersistenceException
    *          if there is an error configuring or obtaining the data source.
    */
   private static JdbcDataSource getDataSource(Database database, DatabaseConfig cfg, String tenantName)
   throws PersistenceException
   {
      DatabaseKey dbKey = new DatabaseKey(database, tenantName);
      JdbcDataSource jds = sources.get(dbKey);
      
      if (jds == null)
      {
         DataSource dataSource = database.isTemporary()
                              ? new TempTableDataSourceProvider().getDataSource(database, cfg)
                              : PooledDataSourceProvider.getInstance().getDataSource(database, cfg, tenantName);
         jds = new JdbcDataSource(dataSource);
         sources.put(dbKey, jds);
      }
      
      return jds;
   }
   
   /**
    * Private constructor; all access is via static methods. Reads configuration data from the
    * application directory and constructs the data source accordingly.
    * 
    * @param   dataSource
    *          The data source. Note that only the database name is used directly to configure the data
    *          source; all other information is read from the directory or is derived, based on the database
    *          type.
    * 
    * @throws  PersistenceException
    *          if there is an error configuring or obtaining the data source.
    */
   private JdbcDataSource(DataSource dataSource)
   throws PersistenceException
   {
      this.dataSource = dataSource;
      
      try (Connection conn = dataSource.getConnection())
      {
         DatabaseMetaData dbMeta = conn.getMetaData();
         this.batch = dbMeta.supportsBatchUpdates();
      }
      catch (SQLException exc)
      {
         // if an error is encountered when using a c3p0 datasource
         // it needs to be closed manually to release its resources
         if (dataSource instanceof ComboPooledDataSource)
         {
            ((ComboPooledDataSource) dataSource).close();
         }
         throw new PersistenceException("Error querying database metadata", exc);
      }
   }
   
   /**
    * Class that handles a database key for the {@code sources} map.
    * This involves one component (the database instance) if we are not in multi-tenancy mode,
    * or two components (the database instance and the tenant id) otherwise.
    */
   private static class DatabaseKey
   {
      /** The database instance to work with. */
      private final Database db;
      
      /** The tenant id. This will be {@code null} if multi-tenancy is not enabled. */
      private final String tenantName;
      
      /** The hash code for the key. */
      private final int hashCode;
      
      /**
       * Basic constructor.
       * 
       * @param   db
       *          The database instance to work with.
       * @param   tenantName
       *          The name of the tenant.
       */
      private DatabaseKey(Database db, String tenantName)
      {
         this.db = db;
         this.tenantName = tenantName;
         hashCode = tenantName != null                                     ? 
                    (37 * 17 + db.hashCode()) * 37 + tenantName.hashCode() : 
                    37 * 17 + db.hashCode();
      }
      
      /**
       * Calculate a hash code for this object.
       * 
       * @return  Hash code.
       */
      @Override
      public int hashCode()
      {
         return hashCode;
      }
      
      /** 
       * Test the equality between this instance and another given one.
       * 
       * @return  {@code true} if the objects match, {@code false} otherwise.
       */
      @Override
      public boolean equals(Object o)
      {
         if (this == o)
         {
            // Same instance.
            return true;
         }
         
         if (!(o instanceof DatabaseKey))
         {
            // Impossible to be equivalent.
            return false;
         }
         
         DatabaseKey that = (DatabaseKey) o;
         
         if (!this.db.equals(that.db))
         {
            return false;
         }
         
         if (this.tenantName != null && that.tenantName == null ||
             this.tenantName == null && that.tenantName != null)
         {
            return false;
         }
         
         if (this.tenantName == null && that.tenantName == null)
         {
            return true;
         }
         
         return this.tenantName.equals(that.tenantName);
      }
   }
}