/*
* Copyright 2012-2017 the original author or authors.
*
* Licensed 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.glowroot.agent.embedded.util;
import java.io.File;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Properties;
import java.util.concurrent.ExecutionException;
import javax.annotation.Nullable;
import javax.annotation.concurrent.GuardedBy;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Joiner;
import com.google.common.base.Throwables;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import org.checkerframework.checker.tainting.qual.Untainted;
import org.h2.jdbc.JdbcConnection;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.glowroot.agent.embedded.util.Schemas.Column;
import org.glowroot.agent.embedded.util.Schemas.Index;
import org.glowroot.common.util.OnlyUsedByTests;
import static com.google.common.base.Preconditions.checkNotNull;
public class DataSource {
private static final Logger logger = LoggerFactory.getLogger(DataSource.class);
private static final int CACHE_SIZE =
Integer.getInteger("glowroot.internal.h2.cacheSize", 8192);
private static final int QUERY_TIMEOUT_SECONDS =
Integer.getInteger("glowroot.internal.h2.queryTimeout", 60);
// null means use memDb
private final @Nullable File dbFile;
private final Thread shutdownHookThread;
private final Object lock = new Object();
@GuardedBy("lock")
private Connection connection;
private volatile boolean closed;
private final Map</*@Untainted*/String, ImmutableList<Column>> tables = Maps.newConcurrentMap();
private final Map</*@Untainted*/String, ImmutableList<Index>> indexes = Maps.newConcurrentMap();
private final LoadingCache</*@Untainted*/ String, PreparedStatement> preparedStatementCache =
CacheBuilder.newBuilder()
.weakValues()
.build(new CacheLoader</*@Untainted*/String, PreparedStatement>() {
@Override
public PreparedStatement load(@Untainted String sql) throws SQLException {
return connection.prepareStatement(sql);
}
});
// creates an in-memory database
public DataSource() throws SQLException {
dbFile = null;
connection = createConnection(null);
shutdownHookThread = new ShutdownHookThread();
Runtime.getRuntime().addShutdownHook(shutdownHookThread);
}
public DataSource(File dbFile) throws SQLException {
this.dbFile = dbFile;
connection = createConnection(dbFile);
shutdownHookThread = new ShutdownHookThread();
Runtime.getRuntime().addShutdownHook(shutdownHookThread);
}
public void defrag() throws SQLException {
if (dbFile == null) {
return;
}
synchronized (lock) {
if (closed) {
return;
}
execute("shutdown defrag");
preparedStatementCache.invalidateAll();
connection = createConnection(dbFile);
}
}
public void deleteAll() throws SQLException {
if (dbFile == null) {
return;
}
synchronized (lock) {
if (closed) {
return;
}
connection.close();
preparedStatementCache.invalidateAll();
boolean success = dbFile.delete();
connection = createConnection(dbFile);
for (Entry</*@Untainted*/String, ImmutableList<Column>> entry : tables.entrySet()) {
syncTable(entry.getKey(), entry.getValue());
}
for (Entry</*@Untainted*/String, ImmutableList<Index>> entry : indexes.entrySet()) {
syncIndexes(entry.getKey(), entry.getValue());
}
if (!success) {
throw new SQLException("Could not delete file: " + dbFile.getAbsolutePath());
}
}
}
public void execute(@Untainted String sql) throws SQLException {
debug(sql);
synchronized (lock) {
if (closed) {
return;
}
Statement statement = connection.createStatement();
// setQueryTimeout() affects all statements of this connection (at least with h2)
statement.setQueryTimeout(0);
StatementCloser closer = new StatementCloser(statement);
try {
statement.execute(sql);
} catch (Throwable t) {
throw closer.rethrow(t);
} finally {
closer.close();
}
}
}
// warning: this method returns 0 when data source is closed
public long queryForLong(final @Untainted String sql, Object... args) throws SQLException {
Long value = queryForOptionalLong(sql, args);
return value == null ? 0L : value;
}
public @Nullable Long queryForOptionalLong(final @Untainted String sql, Object... args)
throws SQLException {
debug(sql, args);
synchronized (lock) {
if (closed) {
return null;
}
return queryUnderLock(sql, args, new ResultSetExtractor</*@Nullable*/ Long>() {
@Override
public @Nullable Long extractData(ResultSet resultSet) throws SQLException {
if (!resultSet.next()) {
return null;
}
long val = resultSet.getLong(1);
Long value = resultSet.wasNull() ? null : val;
if (resultSet.next()) {
logger.warn("more than one row returned: {}", sql);
}
return value;
}
});
}
}
public boolean queryForExists(final @Untainted String sql, Object... args) throws SQLException {
debug(sql, args);
synchronized (lock) {
if (closed) {
return false;
}
return queryUnderLock(sql, args, new ResultSetExtractor<Boolean>() {
@Override
public Boolean extractData(ResultSet resultSet) throws SQLException {
return resultSet.next();
}
});
}
}
public List<String> queryForStringList(final @Untainted String sql) throws SQLException {
return query(new JdbcRowQuery<String>() {
@Override
public @Untainted String getSql() {
return sql;
}
@Override
public void bind(PreparedStatement preparedStatement) {}
@Override
public String mapRow(ResultSet resultSet) throws SQLException {
return checkNotNull(resultSet.getString(1));
}
});
}
public <T> T query(JdbcQuery<T> jdbcQuery) throws Exception {
synchronized (lock) {
if (closed) {
return jdbcQuery.valueIfDataSourceClosed();
}
PreparedStatement preparedStatement =
prepareStatement(jdbcQuery.getSql(), QUERY_TIMEOUT_SECONDS);
jdbcQuery.bind(preparedStatement);
ResultSet resultSet = preparedStatement.executeQuery();
ResultSetCloser closer = new ResultSetCloser(resultSet);
try {
return jdbcQuery.processResultSet(resultSet);
} catch (Throwable t) {
throw closer.rethrow(t);
} finally {
closer.close();
}
// don't need to close statement since they are all cached and used under lock
}
}
public <T extends /*@NonNull*/ Object> /*@Nullable*/ T queryAtMostOne(JdbcRowQuery<T> jdbcQuery)
throws SQLException {
List<T> list = query(jdbcQuery);
if (list.isEmpty()) {
return null;
}
if (list.size() > 1) {
logger.warn("more than one row returned: {}", jdbcQuery.getSql());
}
return list.get(0);
}
public <T extends /*@NonNull*/ Object> List<T> query(JdbcRowQuery<T> jdbcQuery)
throws SQLException {
synchronized (lock) {
if (closed) {
return ImmutableList.of();
}
PreparedStatement preparedStatement =
prepareStatement(jdbcQuery.getSql(), QUERY_TIMEOUT_SECONDS);
jdbcQuery.bind(preparedStatement);
ResultSet resultSet = preparedStatement.executeQuery();
ResultSetCloser closer = new ResultSetCloser(resultSet);
try {
List<T> mappedRows = Lists.newArrayList();
while (resultSet.next()) {
mappedRows.add(jdbcQuery.mapRow(resultSet));
}
return ImmutableList.copyOf(mappedRows);
} catch (Throwable t) {
throw closer.rethrow(t);
} finally {
closer.close();
}
// don't need to close statement since they are all cached and used under lock
}
}
public int update(final @Untainted String sql, final @Nullable Object... args)
throws SQLException {
return update(new JdbcUpdate() {
@Override
public @Untainted String getSql() {
return sql;
}
@Override
public void bind(PreparedStatement preparedStatement) throws SQLException {
for (int i = 0; i < args.length; i++) {
preparedStatement.setObject(i + 1, args[i]);
}
}
});
}
public int update(JdbcUpdate jdbcUpdate) throws SQLException {
if (closed) {
// this can get called a lot inserting traces, and these can get backlogged
// on the lock below during jvm shutdown without pre-checking here (and backlogging
// ends up generating warning messages from
// TransactionCollectorImpl.logPendingLimitWarning())
return 0;
}
synchronized (lock) {
if (closed) {
return 0;
}
PreparedStatement preparedStatement = prepareStatement(jdbcUpdate.getSql(), 0);
jdbcUpdate.bind(preparedStatement);
return preparedStatement.executeUpdate();
// don't need to close statement since they are all cached and used under lock
}
}
public int[] batchUpdate(JdbcUpdate jdbcUpdate) throws Exception {
if (closed) {
// this can get called a lot inserting traces, and these can get backlogged
// on the lock below during jvm shutdown without pre-checking here (and backlogging
// ends up generating warning messages from
// TransactionCollectorImpl.logPendingLimitWarning())
return new int[0];
}
synchronized (lock) {
if (closed) {
return new int[0];
}
PreparedStatement preparedStatement = prepareStatement(jdbcUpdate.getSql(), 0);
jdbcUpdate.bind(preparedStatement);
return preparedStatement.executeBatch();
// don't need to close statement since they are all cached and used under lock
}
}
public void deleteBefore(@Untainted String tableName, long captureTime) throws SQLException {
// delete 100 at a time, which is both faster than deleting all at once, and doesn't
// lock the single jdbc connection for one large chunk of time
int deleted;
do {
deleted = update("delete from " + tableName + " where capture_time < ? limit 100",
captureTime);
} while (deleted > 0);
}
public void syncTable(@Untainted String tableName, List<Column> columns) throws SQLException {
synchronized (lock) {
if (closed) {
return;
}
Schemas.syncTable(tableName, columns, connection);
tables.put(tableName, ImmutableList.copyOf(columns));
}
}
public void syncIndexes(@Untainted String tableName, ImmutableList<Index> indexes)
throws SQLException {
synchronized (lock) {
if (closed) {
return;
}
Schemas.syncIndexes(tableName, indexes, connection);
this.indexes.put(tableName, indexes);
}
}
long getDbFileSize() {
return dbFile == null ? 0 : dbFile.length();
}
// helpful for upgrading schema
public boolean tableExists(String tableName) throws SQLException {
synchronized (lock) {
return !closed && Schemas.tableExists(tableName, connection);
}
}
// helpful for upgrading schema
public boolean columnExists(String tableName, String columnName) throws SQLException {
synchronized (lock) {
return !closed && Schemas.columnExists(tableName, columnName, connection);
}
}
// helpful for upgrading schema
public void renameTable(@Untainted String oldTableName, @Untainted String newTableName)
throws SQLException {
if (Schemas.tableExists(oldTableName, connection)) {
execute("alter table " + oldTableName + " rename to " + newTableName);
}
}
// helpful for upgrading schema
public void renameColumn(@Untainted String tableName, @Untainted String oldColumnName,
@Untainted String newColumnName) throws SQLException {
if (Schemas.columnExists(tableName, oldColumnName, connection)) {
execute("alter table " + tableName + " alter column " + oldColumnName + " rename to "
+ newColumnName);
}
}
@OnlyUsedByTests
public void close() throws SQLException {
synchronized (lock) {
if (closed) {
return;
}
closed = true;
connection.close();
}
Runtime.getRuntime().removeShutdownHook(shutdownHookThread);
}
// lock must be acquired prior to calling this method
private <T extends /*@Nullable*/ Object> T queryUnderLock(@Untainted String sql, Object[] args,
ResultSetExtractor<T> rse) throws SQLException {
PreparedStatement preparedStatement = prepareStatement(sql, QUERY_TIMEOUT_SECONDS);
for (int i = 0; i < args.length; i++) {
preparedStatement.setObject(i + 1, args[i]);
}
ResultSet resultSet = preparedStatement.executeQuery();
ResultSetCloser closer = new ResultSetCloser(resultSet);
try {
return rse.extractData(resultSet);
} catch (Throwable t) {
throw closer.rethrow(t);
} finally {
closer.close();
}
// don't need to close statement since they are all cached and used under lock
}
private PreparedStatement prepareStatement(@Untainted String sql, int queryTimeoutSeconds)
throws SQLException {
try {
PreparedStatement preparedStatement = preparedStatementCache.get(sql);
// setQueryTimeout() affects all statements of this connection (at least with h2)
preparedStatement.setQueryTimeout(queryTimeoutSeconds);
return preparedStatement;
} catch (ExecutionException e) {
Throwable cause = e.getCause();
Throwables.propagateIfPossible(cause, SQLException.class);
// it should not really be possible to get here since the only checked exception that
// preparedStatementCache's CacheLoader throws is SQLException
logger.error(e.getMessage(), e);
throw new SQLException(e);
}
}
private static Connection createConnection(@Nullable File dbFile) throws SQLException {
if (dbFile == null) {
// db_close_on_exit=false since jvm shutdown hook is handled by DataSource
return new JdbcConnection("jdbc:h2:mem:;compress=true;db_close_on_exit=false",
new Properties());
} else {
String dbPath = dbFile.getPath();
dbPath = dbPath.replaceFirst(".h2.db$", "");
Properties props = new Properties();
props.setProperty("user", "sa");
props.setProperty("password", "");
// db_close_on_exit=false since jvm shutdown hook is handled by DataSource
String url = "jdbc:h2:" + dbPath + ";compress=true;db_close_on_exit=false;cache_size="
+ CACHE_SIZE;
return new JdbcConnection(url, props);
}
}
private static void debug(String sql, @Nullable Object... args) {
debug(logger, sql, args);
}
@VisibleForTesting
static void debug(Logger logger, String sql, @Nullable Object... args) {
if (!logger.isDebugEnabled()) {
return;
}
if (args.length == 0) {
logger.debug(sql);
return;
}
List<String> argStrings = Lists.newArrayList();
for (Object arg : args) {
if (arg instanceof String) {
argStrings.add('\'' + (String) arg + '\'');
} else if (arg == null) {
argStrings.add("NULL");
} else {
argStrings.add(arg.toString());
}
}
logger.debug("{} [{}]", sql, Joiner.on(", ").join(argStrings));
}
public interface JdbcQuery<T> {
@Untainted
String getSql();
void bind(PreparedStatement preparedStatement) throws Exception;
T processResultSet(ResultSet resultSet) throws Exception;
T valueIfDataSourceClosed();
}
public interface JdbcRowQuery<T> {
@Untainted
String getSql();
void bind(PreparedStatement preparedStatement) throws SQLException;
T mapRow(ResultSet resultSet) throws Exception;
}
public interface JdbcUpdate {
@Untainted
String getSql();
void bind(PreparedStatement preparedStatement) throws SQLException;
}
private interface ResultSetExtractor<T extends /*@Nullable*/ Object> {
T extractData(ResultSet resultSet) throws Exception;
}
// this replaces H2's default shutdown hook (see jdbc connection db_close_on_exit=false above)
// in order to prevent exceptions from occurring (and getting logged) during shutdown in the
// case that there are still traces being written
private class ShutdownHookThread extends Thread {
@Override
public void run() {
try {
// update flag outside of lock in case there is a backlog of threads already
// waiting on the lock (once the flag is set, any threads in the backlog that
// haven't acquired the lock will abort quickly once they do obtain the lock)
closed = true;
synchronized (lock) {
connection.close();
}
} catch (SQLException e) {
logger.warn(e.getMessage(), e);
}
}
}
}