/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.jackrabbit.core.util.db;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import javax.sql.DataSource;
import org.apache.jackrabbit.data.core.TransactionContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This class provides convenience methods to execute SQL statements. They can be either executed in isolation
* or within the context of a JDBC transaction; the so-called batch mode (use the {@link #startBatch()}
* and {@link #endBatch(boolean)} methods for this).
*
*
*
* This class contains logic to retry execution of SQL statements. If this helper is not in batch mode
* and if a statement fails due to an {@code SQLException}, then it is retried. If the {@code block} argument
* of the constructor call was {@code false} then it is retried only once. Otherwise the statement is retried
* until either it succeeds or the thread is interrupted. This clearly assumes that the only cause of {@code
* SQLExceptions} is faulty {@code Connections} which are restored eventually. Note :
* This retry logic only applies to the following methods:
*
* {@link #exec(String, Object...)}
* {@link #update(String, Object[])}
* {@link #exec(String, Object[], boolean, int)}
*
*
*
*
* This class is not thread-safe and if it is to be used by multiple threads then the clients must make sure
* that access to this class is properly synchronized.
*
*
*
* Implementation note : The {@code Connection} that is retrieved from the {@code DataSource}
* in {@link #getConnection(boolean)} may be broken. This is so because if an internal {@code DataSource} is used,
* then this is a commons-dbcp {@code DataSource} with a testWhileIdle
validation strategy (see
* the {@code ConnectionFactory} class). Furthermore, if it is a {@code DataSource} obtained through JNDI then we
* can make no assumptions about the validation strategy. This means that our retry logic must either assume that
* the SQL it tries to execute can do so without errors (i.e., the statement is valid), or it must implement its
* own validation strategy to apply. Currently, the former is in place.
*/
public class ConnectionHelper {
static Logger log = LoggerFactory.getLogger(ConnectionHelper.class);
private static final int RETRIES = 1;
private static final int SLEEP_BETWEEN_RETRIES_MS = 100;
final boolean blockOnConnectionLoss;
private final boolean checkTablesWithUserName;
protected final DataSource dataSource;
private Map batchConnectionMap = Collections.synchronizedMap(new HashMap());
/**
* The default fetchSize is '0'. This means the fetchSize Hint will be ignored
*/
private int fetchSize = 0;
/**
* @param dataSrc the {@link DataSource} on which this instance acts
* @param block whether the helper should transparently block on DB connection loss (otherwise it retries
* once and if that fails throws exception)
*/
public ConnectionHelper(DataSource dataSrc, boolean block) {
dataSource = dataSrc;
checkTablesWithUserName = false;
blockOnConnectionLoss = block;
}
/**
* @param dataSrc the {@link DataSource} on which this instance acts
* @param checkWithUserName whether the username is to be used for the {@link #tableExists(String)} method
* @param block whether the helper should transparently block on DB connection loss (otherwise it throws exceptions)
*/
protected ConnectionHelper(DataSource dataSrc, boolean checkWithUserName, boolean block) {
dataSource = dataSrc;
checkTablesWithUserName = checkWithUserName;
blockOnConnectionLoss = block;
}
/**
* @param dataSrc the {@link DataSource} on which this instance acts
* @param checkWithUserName whether the username is to be used for the {@link #tableExists(String)} method
* @param block whether the helper should transparently block on DB connection loss (otherwise it throws exceptions)
* @param fetchSize the fetchSize that will be used per default
*/
protected ConnectionHelper(DataSource dataSrc, boolean checkWithUserName, boolean block, int fetchSize) {
dataSource = dataSrc;
checkTablesWithUserName = checkWithUserName;
blockOnConnectionLoss = block;
this.fetchSize = fetchSize;
}
/**
* A utility method that makes sure that identifier
does only consist of characters that are
* allowed in names on the target database. Illegal characters will be escaped as necessary.
*
* This method is not affected by the
*
* @param identifier the identifier to convert to a db specific identifier
* @return the db-normalized form of the given identifier
* @throws SQLException if an error occurs
*/
public final String prepareDbIdentifier(String identifier) throws SQLException {
if (identifier == null) {
return null;
}
String legalChars = "ABCDEFGHIJKLMNOPQRSTUVWXZY0123456789_";
legalChars += getExtraNameCharacters();
String id = identifier.toUpperCase();
StringBuilder escaped = new StringBuilder();
for (int i = 0; i < id.length(); i++) {
char c = id.charAt(i);
if (legalChars.indexOf(c) == -1) {
replaceCharacter(escaped, c);
} else {
escaped.append(c);
}
}
return escaped.toString();
}
/**
* Called from {@link #prepareDbIdentifier(String)}. Default implementation replaces the illegal
* characters with their hexadecimal encoding.
*
* @param escaped the escaped db identifier
* @param c the character to replace
*/
protected void replaceCharacter(StringBuilder escaped, char c) {
escaped.append("_x");
String hex = Integer.toHexString(c);
escaped.append("0000".toCharArray(), 0, 4 - hex.length());
escaped.append(hex);
escaped.append("_");
}
/**
* Returns true if we are currently in a batch mode, false otherwise.
*
* @return true if the current thread or the active transaction is running in batch mode, false otherwise.
*/
protected boolean inBatchMode() {
return getTransactionAwareBatchConnection() != null;
}
/**
* The default implementation returns the {@code extraNameCharacters} provided by the databases metadata.
*
* @return the additional characters for identifiers supported by the db
* @throws SQLException on error
*/
private String getExtraNameCharacters() throws SQLException {
Connection con = dataSource.getConnection();
try {
DatabaseMetaData metaData = con.getMetaData();
return metaData.getExtraNameCharacters();
} finally {
DbUtility.close(con, null, null);
}
}
/**
* Checks whether the given table exists in the database.
*
* @param tableName the name of the table
* @return whether the given table exists
* @throws SQLException on error
*/
public final boolean tableExists(String tableName) throws SQLException {
Connection con = dataSource.getConnection();
ResultSet rs = null;
boolean schemaExists = false;
String name = tableName;
try {
DatabaseMetaData metaData = con.getMetaData();
if (metaData.storesLowerCaseIdentifiers()) {
name = tableName.toLowerCase();
} else if (metaData.storesUpperCaseIdentifiers()) {
name = tableName.toUpperCase();
}
String userName = null;
if (checkTablesWithUserName) {
userName = metaData.getUserName();
}
rs = metaData.getTables(null, userName, name, null);
schemaExists = rs.next();
} finally {
DbUtility.close(con, null, rs);
}
return schemaExists;
}
/**
* Starts the batch mode . If an {@link SQLException} is thrown, then the batch mode is not started.
*
* Important: clients that call this method must make sure that
* {@link #endBatch(boolean)} is called eventually.
*
* @throws SQLException on error
*/
public final void startBatch() throws SQLException {
if (inBatchMode()) {
throw new SQLException("already in batch mode");
}
Connection batchConnection = null;
try {
batchConnection = getConnection(false);
batchConnection.setAutoCommit(false);
setTransactionAwareBatchConnection(batchConnection);
} catch (SQLException e) {
removeTransactionAwareBatchConnection();
// Strive for failure atomicity
if (batchConnection != null) {
DbUtility.close(batchConnection, null, null);
}
throw e;
}
}
/**
* This method always ends the batch mode .
*
* @param commit whether the changes in the batch should be committed or rolled back
* @throws SQLException if the commit or rollback of the underlying JDBC Connection threw an {@code
* SQLException}
*/
public final void endBatch(boolean commit) throws SQLException {
if (!inBatchMode()) {
throw new SQLException("not in batch mode");
}
Connection batchConnection = getTransactionAwareBatchConnection();
try {
if (commit) {
batchConnection.commit();
} else {
batchConnection.rollback();
}
} finally {
removeTransactionAwareBatchConnection();
if (batchConnection != null) {
DbUtility.close(batchConnection, null, null);
}
}
}
/**
* Executes a general SQL statement and immediately closes all resources.
*
* Note: We use a Statement if there are no parameters to avoid a problem on
* the Oracle 10g JDBC driver w.r.t. :NEW and :OLD keywords that triggers ORA-17041.
*
* @param sql an SQL statement string
* @param params the parameters for the SQL statement
* @throws SQLException on error
*/
public final void exec(final String sql, final Object... params) throws SQLException {
new RetryManager(params) {
@Override
protected Void call() throws SQLException {
reallyExec(sql, params);
return null;
}
}.doTry();
}
void reallyExec(String sql, Object... params) throws SQLException {
Connection con = null;
Statement stmt = null;
boolean inBatchMode = inBatchMode();
try {
con = getConnection(inBatchMode);
if (params == null || params.length == 0) {
stmt = con.createStatement();
stmt.execute(sql);
} else {
PreparedStatement p = con.prepareStatement(sql);
stmt = p;
execute(p, params);
}
} finally {
closeResources(con, stmt, null, inBatchMode);
}
}
/**
* Executes an update or delete statement and returns the update count.
*
* @param sql an SQL statement string
* @param params the parameters for the SQL statement
* @return the update count
* @throws SQLException on error
*/
public final int update(final String sql, final Object... params) throws SQLException {
return new RetryManager(params) {
@Override
protected Integer call() throws SQLException {
return reallyUpdate(sql, params);
}
}.doTry();
}
int reallyUpdate(String sql, Object... params) throws SQLException {
Connection con = null;
PreparedStatement stmt = null;
boolean inBatchMode = inBatchMode();
try {
con = getConnection(inBatchMode);
stmt = con.prepareStatement(sql);
return execute(stmt, params).getUpdateCount();
} finally {
closeResources(con, stmt, null, inBatchMode);
}
}
/**
* Executes a SQL query and returns the {@link ResultSet}. The
* returned {@link ResultSet} should be closed by clients.
*
* @param sql an SQL statement string
* @param params the parameters for the SQL statement
* @return a {@link ResultSet}
*/
public final ResultSet query(String sql, Object... params) throws SQLException {
return exec(sql, params, false, 0);
}
/**
* Executes a general SQL statement and returns the {@link ResultSet} of the executed statement. The
* returned {@link ResultSet} should be closed by clients.
*
* @param sql an SQL statement string
* @param params the parameters for the SQL statement
* @param returnGeneratedKeys whether generated keys should be returned
* @param maxRows the maximum number of rows in a potential {@link ResultSet} (0 means no limit)
* @return a {@link ResultSet}
* @throws SQLException on error
*/
public final ResultSet exec(final String sql, final Object[] params, final boolean returnGeneratedKeys,
final int maxRows) throws SQLException {
return new RetryManager(params) {
@Override
protected ResultSet call() throws SQLException {
return reallyExec(sql, params, returnGeneratedKeys, maxRows);
}
}.doTry();
}
ResultSet reallyExec(String sql, Object[] params, boolean returnGeneratedKeys, int maxRows)
throws SQLException {
Connection con = null;
PreparedStatement stmt = null;
ResultSet rs = null;
boolean inBatchMode = inBatchMode();
try {
con = getConnection(inBatchMode);
if (returnGeneratedKeys) {
stmt = con.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
} else {
stmt = con.prepareStatement(sql);
}
stmt.setMaxRows(maxRows);
int currentFetchSize = this.fetchSize;
if (0 < maxRows && maxRows < currentFetchSize) {
currentFetchSize = maxRows; // JCR-3090
}
stmt.setFetchSize(currentFetchSize);
execute(stmt, params);
if (returnGeneratedKeys) {
rs = stmt.getGeneratedKeys();
} else {
rs = stmt.getResultSet();
}
// Don't wrap null
if (rs == null) {
closeResources(con, stmt, rs, inBatchMode);
return null;
}
if (inBatchMode) {
return ResultSetWrapper.newInstance(null, stmt, rs);
} else {
return ResultSetWrapper.newInstance(con, stmt, rs);
}
} catch (SQLException e) {
closeResources(con, stmt, rs, inBatchMode);
throw e;
}
}
/**
* Gets a connection based on the {@code batchMode} state of this helper. The connection should be closed
* by a call to {@link #closeResources(Connection, Statement, ResultSet, boolean)} which also takes the {@code
* batchMode} state into account.
*
* @param inBatchMode indicates if we are in a batchMode
* @return a {@code Connection} to use, based on the batch mode state
* @throws SQLException on error
*/
protected final Connection getConnection(boolean inBatchMode) throws SQLException {
if (inBatchMode) {
return getTransactionAwareBatchConnection();
} else {
Connection con = dataSource.getConnection();
// JCR-1013: Setter may fail unnecessarily on a managed connection
if (!con.getAutoCommit()) {
con.setAutoCommit(true);
}
return con;
}
}
/**
* Returns the Batch Connection.
*
* @return Connection
*/
private Connection getTransactionAwareBatchConnection() {
Object threadId = TransactionContext.getCurrentThreadId();
return batchConnectionMap.get(threadId);
}
/**
* Stores the given Connection to the batchConnectionMap.
* If we are running in a XA Environment the globalTransactionId will be used as Key.
* In Non-XA Environment the ThreadName is used.
*
* @param batchConnection
*/
private void setTransactionAwareBatchConnection(Connection batchConnection) {
Object threadId = TransactionContext.getCurrentThreadId();
batchConnectionMap.put(threadId, batchConnection);
}
/**
* Removes the Batch Connection from the batchConnectionMap
*/
private void removeTransactionAwareBatchConnection() {
Object threadId = TransactionContext.getCurrentThreadId();
batchConnectionMap.remove(threadId);
}
/**
* Closes the given resources given the {@code batchMode} state.
*
* @param con the {@code Connection} obtained through the {@link #getConnection(boolean)} method
* @param stmt a {@code Statement}
* @param rs a {@code ResultSet}
* @param inBatchMode indicates if we are in a batchMode
*/
protected final void closeResources(Connection con, Statement stmt, ResultSet rs, boolean inBatchMode) {
if (inBatchMode) {
DbUtility.close(null, stmt, rs);
} else {
DbUtility.close(con, stmt, rs);
}
}
/**
* This method is used by all methods of this class that execute SQL statements. This default
* implementation sets all parameters and unwraps {@link StreamWrapper} instances. Subclasses may override
* this method to do something special with the parameters. E.g., the {@link Oracle10R1ConnectionHelper}
* overrides it in order to add special blob handling.
*
* @param stmt the {@link PreparedStatement} to execute
* @param params the parameters
* @return the executed statement
* @throws SQLException on error
*/
protected PreparedStatement execute(PreparedStatement stmt, Object[] params) throws SQLException {
for (int i = 0; params != null && i < params.length; i++) {
Object p = params[i];
if (p instanceof StreamWrapper) {
StreamWrapper wrapper = (StreamWrapper) p;
stmt.setBinaryStream(i + 1, wrapper.getStream(), (int) wrapper.getSize());
} else {
stmt.setObject(i + 1, p);
}
}
stmt.execute();
return stmt;
}
/**
* This class encapsulates the logic to retry a method invocation if it threw an SQLException.
* The RetryManager must cleanup the Params it will get.
*
* @param the return type of the method which is retried if it failed
*/
public abstract class RetryManager {
private Object[] params;
public RetryManager(Object[] params) {
this.params = params;
}
public final T doTry() throws SQLException {
try {
if (inBatchMode()) {
return call();
} else {
boolean sleepInterrupted = false;
int failures = 0;
SQLException lastException = null;
while (!sleepInterrupted && (blockOnConnectionLoss || failures <= RETRIES)) {
try {
return call();
} catch (SQLException e) {
lastException = e;
}
log.error("Failed to execute SQL (stacktrace on DEBUG log level): " + lastException);
log.debug("Failed to execute SQL", lastException);
if (!resetParamResources()) {
log.warn("Could not reset parameters: not retrying SQL call");
break;
}
failures++;
if (blockOnConnectionLoss || failures <= RETRIES) { // if we're going to try again
try {
Thread.sleep(SLEEP_BETWEEN_RETRIES_MS);
} catch (InterruptedException e1) {
Thread.currentThread().interrupt();
sleepInterrupted = true;
log.error("Interrupted: canceling retry");
}
}
}
throw lastException;
}
} finally {
cleanupParamResources();
}
}
protected abstract T call() throws SQLException;
/**
* Cleans up the Parameter resources that are not automatically closed or deleted.
*/
protected void cleanupParamResources() {
for (int i = 0; params != null && i < params.length; i++) {
Object p = params[i];
if (p instanceof StreamWrapper) {
StreamWrapper wrapper = (StreamWrapper) p;
wrapper.closeStream();
}
}
}
protected boolean resetParamResources() {
for (int i = 0; params != null && i < params.length; i++) {
Object p = params[i];
if (p instanceof StreamWrapper) {
StreamWrapper wrapper = (StreamWrapper) p;
if(!wrapper.resetStream()) {
return false;
}
}
}
return true;
}
}
}