Bug #74932 ConnectionImp Doesn't Close Server Prepared Statement (PreparedStatement Leak)
Submitted: 19 Nov 2014 23:54 Modified: 14 Jul 2017 21:09
Reporter: Jason Chan (OCA) Email Updates:
Status: Closed Impact on me:
None 
Category:Connector / J Severity:S3 (Non-critical)
Version:5.1.34, 5.1.15 OS:Any
Assigned to: Filipe Silva CPU Architecture:Any
Tags: connectorj, jdbc

[19 Nov 2014 23:54] Jason Chan
Description:
When server prepared statement is enabled, a PreparedStatement is pulled from a LRU cache (serverSideStatementCache) in ConnectionImpl.java upon requested. If another PreparedStatement of the same SQL is run and closed before the first one is closed, the first statement will be put back to the cache with the same key. Because realClose() is only called on cache eviction, the second statement will never be closed until the connection is closed. This will eventually lead to the error "Can't create more than max_prepared_stmt_count statements" if there are many connections and each connection holds a number of PreparedStatement.

----------------------------------------------------------
Here is the the pseudocode to describe the issue:

Try to prepare a Server Side Statement
Get a cache hit, so remove the Prepared Statement from the cache and get a hold of it
Execute the statement
Loop over the result {
     Try to Prepare the same server side statement
     No cache hit because we removed it early, so prepare a new statement
     Execute the statement and get the result     
     Closing the statement will cache the statement
}
Closing the statement will cache the statement with the same key, losing track of the statement created.

How to repeat:
1) Run the following Java program.
2) While the program is running, observe that the Prepared_stmt_count keeps increasing by issuing [show global status like 'Prepared_stmt_count].

------------------------------------------------------------
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

/**
 * Creat a test database:
 * <pre>      
   CREATE TABLE `test_table` (
    `col1` int(10) unsigned NOT NULL,
    `col2` int(10) unsigned NOT NULL,
    `col3` int(10) unsigned NOT NULL,
    PRIMARY KEY (`col1`)
   ) ENGINE=InnoDB DEFAULT CHARSET=utf8;

   insert into `test_table` values (1,2,3);
   insert into `test_table` values (2,2,3);
   insert into `test_table` values (3,2,3);

   insert into `test_table` values (4,5,6);
   insert into `test_table` values (7,8,9);
 * </pre>
 *
 */
public class PreparedStatementLeak {

    private static final String USERNAME = "root";
    private static final String PASSWORD = "pass";
    private static final String SQL1 = "select col1 from test_table where col1 = ?";
    private static final String SQL2 = "select col1 from test_table where col2 = ?";

    public static void main(final String[] args)
            throws Exception {
        final PreparedStatementLeak test = new PreparedStatementLeak();
        test.init();
        test.runTest();
    }

    private void init()
            throws Exception {
        Class.forName("com.mysql.jdbc.Driver").newInstance();
    }

    private void runTest()
            throws SQLException {
        Connection conn = null;
        try {
            conn = DriverManager.getConnection(
                    "jdbc:mysql://localhost/testdb?prepStmtCacheSize=10&cachePrepStmts=true&useServerPrepStmts=true",
                    USERNAME, PASSWORD);
            for (int i = 0; i < 100000; i++) {
                // inner statement is the same as outer statement
                prepareAndRun(conn, SQL1);

                // inner statement is different from outer statement
                //prepareAndRun(conn, SQL2);
            }
        }
        finally {
            if (conn != null) {
                conn.close();
            }
        }
    }

    private void prepareAndRun(final Connection conn, final String sql)
            throws SQLException {
        PreparedStatement ps1 = null;
        PreparedStatement ps2 = null;
        ResultSet rs1 = null;
        ResultSet rs2 = null;

        try {
            ps1 = conn.prepareStatement(SQL1);
            ps1.setInt(1, 1);
            rs1 = ps1.executeQuery();
            while (rs1.next()) {
                try {
                    ps2 = conn.prepareStatement(sql);
                    ps2.setInt(1, 2);
                    rs2 = ps2.executeQuery();
                    System.out.println("Outer: " + rs1.getInt(1));
                    while (rs2.next()) {
                        System.out.println("Inner: " + rs1.getInt(1));
                    }
                }
                finally {
                    if (rs2 != null) {
                        rs2.close();
                    }
                    ps2.close();
                }
            }
        }
        catch (SQLException ex) {

            // handle any errors
            System.out.println("SQLException: " + ex.getMessage());
            System.out.println("SQLState: " + ex.getSQLState());
            System.out.println("VendorError: " + ex.getErrorCode());
            ex.printStackTrace();
        }
        finally {
            if (rs1 != null) {
                rs1.close();
            }
            if (rs2 != null) {
                rs2.close();
            }
            if (ps1 != null) {
                ps1.close();
            }
        }
    }
}

Suggested fix:

diff -rupN /home/developer/mysql-connector-java-5.1.15/src/com/mysql/jdbc/ConnectionImpl.java /home/developer/mysql-connector-java-5.1.15-modified/src/com/mysql/jdbc/ConnectionImpl.java
--- /home/developer/mysql-connector-java-5.1.15/src/com/mysql/jdbc/ConnectionImpl.java	2011-02-08 18:25:30.000000000 +0000
+++ /home/developer/mysql-connector-java-5.1.15-modified/src/com/mysql/jdbc/ConnectionImpl.java	2014-11-06 18:07:13.000000000 +0000
@@ -4426,12 +4426,23 @@ public class ConnectionImpl extends Conn
 
 	}
 
-	public void recachePreparedStatement(ServerPreparedStatement pstmt) throws SQLException {
+	/**
+	* @param pstmt
+	* @return true if the statement was not in the cache and was put into the cache as a result 
+	* of this method. A return value of false means that given statement is already in the cache
+	* or the statement was not cached.
+	*/
+	public boolean recachePreparedStatement(ServerPreparedStatement pstmt) throws SQLException {
 		if (pstmt.isPoolable()) {
 			synchronized (this.serverSideStatementCache) {
+				if (this.serverSideStatementCache.containsKey(pstmt.originalSql)) {
+					return false;
+				}
 				this.serverSideStatementCache.put(pstmt.originalSql, pstmt);
+        		return true;
 			}
 		}
+		return false;
 	}
 
 	/**
diff -rupN /home/developer/mysql-connector-java-5.1.15/src/com/mysql/jdbc/LoadBalancedMySQLConnection.java /home/developer/mysql-connector-java-5.1.15-modified/src/com/mysql/jdbc/LoadBalancedMySQLConnection.java
--- /home/developer/mysql-connector-java-5.1.15/src/com/mysql/jdbc/LoadBalancedMySQLConnection.java	2011-02-08 18:25:30.000000000 +0000
+++ /home/developer/mysql-connector-java-5.1.15-modified/src/com/mysql/jdbc/LoadBalancedMySQLConnection.java	2014-11-05 02:29:00.000000000 +0000
@@ -2196,10 +2196,10 @@ public class LoadBalancedMySQLConnection
 				skipLocalTeardown, reason);
 	}
 
-	public void recachePreparedStatement(ServerPreparedStatement pstmt)
+	public boolean recachePreparedStatement(ServerPreparedStatement pstmt)
 			throws SQLException {
 
-		getActiveMySQLConnection().recachePreparedStatement(pstmt);
+		return getActiveMySQLConnection().recachePreparedStatement(pstmt);
 	}
 
 	public void registerQueryExecutionTime(long queryTimeMs) {
diff -rupN /home/developer/mysql-connector-java-5.1.15/src/com/mysql/jdbc/MySQLConnection.java /home/developer/mysql-connector-java-5.1.15-modified/src/com/mysql/jdbc/MySQLConnection.java
--- /home/developer/mysql-connector-java-5.1.15/src/com/mysql/jdbc/MySQLConnection.java	2011-02-08 18:25:30.000000000 +0000
+++ /home/developer/mysql-connector-java-5.1.15-modified/src/com/mysql/jdbc/MySQLConnection.java	2014-11-05 02:28:06.000000000 +0000
@@ -167,7 +167,7 @@ public interface MySQLConnection extends
 	void realClose(boolean calledExplicitly, boolean issueRollback,
 			boolean skipLocalTeardown, Throwable reason) throws SQLException;
 
-	void recachePreparedStatement(ServerPreparedStatement pstmt)
+	boolean recachePreparedStatement(ServerPreparedStatement pstmt)
 			throws SQLException;
 
 	void registerQueryExecutionTime(long queryTimeMs);
diff -rupN /home/developer/mysql-connector-java-5.1.15/src/com/mysql/jdbc/ServerPreparedStatement.java /home/developer/mysql-connector-java-5.1.15-modified/src/com/mysql/jdbc/ServerPreparedStatement.java
--- /home/developer/mysql-connector-java-5.1.15/src/com/mysql/jdbc/ServerPreparedStatement.java	2011-02-08 18:25:30.000000000 +0000
+++ /home/developer/mysql-connector-java-5.1.15-modified/src/com/mysql/jdbc/ServerPreparedStatement.java	2014-11-06 18:07:56.000000000 +0000
@@ -594,7 +594,9 @@ public class ServerPreparedStatement ext
 			
 			this.isClosed = true;
 			
-			this.connection.recachePreparedStatement(this);
+			if (this.isPoolable() && !this.connection.recachePreparedStatement(this)) {
+				realClose(true,true);
+      		}
 			return;
 		}
[20 Nov 2014 2:13] Jason Chan
ServerPreparedStatement#close() should be as follows:

public synchronized void close() throws SQLException {                                                                                  
    if (this.isCached && !this.isClosed) {
        clearParameters();   
        if (this.isPoolable() && !this.connection.recachePreparedStatement(this)) {
            realClose(true,true);
        } else {
            this.isClosed = true;
        }
        return;
    }

    realClose(true, true);
}
[20 Nov 2014 10:12] MySQL Verification Team
Hello Jason Chan,

Thank you for the report and test case.
Observed that Prepared_stmt_count reaches to 16382.
Verifying based on internal discussion with Dev's.

Thanks,
Umesh
[10 Feb 2015 18:23] Jason Chan
Hi Filipe, ios there a plan to address this issue in the upcoming releases?
[14 Jul 2017 21:09] Daniel So
Posted by developer:
 
Added the following entry to the Connector/J 5.1.38 changelog:

"A server-side prepared statement was not closed when the same statement was being prepared again while the original statement was being cached. This was caused by the silent replacement of the cache entry of the old statement by the new. When this happened repeatedly, it caused eventually the complaint max_prepared_stmt_count was exceeded. This fix makes sure that when a cache entry for a statement replaces an older one, the older statement is immediately closed."
[29 Aug 2017 20:08] Daniel So
Posted by developer:
 
Added the changelog entry for Connector/J 8.0.8.