/*
* ModeShape (http://www.modeshape.org)
*
* 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.modeshape.persistence.relational;
import java.io.IOException;
import java.io.InputStream;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.modeshape.common.database.DatabaseType;
import org.modeshape.common.logging.Logger;
import org.modeshape.common.util.StringUtil;
import org.modeshape.schematic.SchematicDb;
import org.modeshape.schematic.SchematicEntry;
import org.modeshape.schematic.document.Document;
import org.modeshape.schematic.document.EditableDocument;
/**
* {@link SchematicDb} implementation which stores data in Relational databases.
*
* @author Horia Chiorean (hchiorea@redhat.com)
* @since 5.0
*/
public class RelationalDb implements SchematicDb {
private static final Logger LOGGER = Logger.getLogger(RelationalDb.class);
private final ConcurrentMap<String, Connection> connectionsByTxId;
private final DataSourceManager dsManager;
private final RelationalDbConfig config;
private final Statements statements;
private final TransactionalCaches transactionalCaches;
protected RelationalDb(Document configDoc) {
this.connectionsByTxId = new ConcurrentHashMap<>();
configDoc = Objects.requireNonNull(configDoc, "Configuration document cannot be null");
this.config = new RelationalDbConfig(configDoc);
this.dsManager = new DataSourceManager(config);
DatabaseType dbType = dsManager.dbType();
this.statements = createStatements(dbType);
this.transactionalCaches = new TransactionalCaches();
}
private Statements createStatements(DatabaseType dbType) {
Map<String, String> statementsFile = loadStatementsResource();
switch (dbType.name()) {
case ORACLE:
return new OracleStatements(config, statementsFile);
case SQLSERVER:
return new SQLServerStatements(config, statementsFile);
case DB2: {
return new DB2Statements(config, statementsFile);
}
default:
return new DefaultStatements(config, statementsFile);
}
}
@Override
public String id() {
return config.name();
}
@Override
public void start() {
if (config.createOnStart()) {
runWithConnection(statements::createTable, false);
}
}
@Override
public void stop() {
// remove the active tx Id
TransactionsHolder.clearActiveTransaction();
// cleanup any possible active connections....
cleanupConnections();
// drop the table if configured to do so
if (config.dropOnExit()) {
runWithConnection(statements::dropTable, false);
}
// and release any idle connections
dsManager.close();
// and clear the caches
transactionalCaches.stop();
}
private void cleanupConnections() {
if (connectionsByTxId.isEmpty()) {
return;
}
LOGGER.warn(RelationalProviderI18n.warnConnectionsNeedCleanup, connectionsByTxId.size());
// this should not normally happen because each flow should end with either a commit/rollback which should release
// the allocated connection
for (Iterator<Map.Entry<String, Connection>> iterator = connectionsByTxId.entrySet().iterator(); iterator.hasNext();) {
Map.Entry<String, Connection> entry = iterator.next();
closeConnection(entry.getKey(), entry.getValue());
iterator.remove();
}
}
private void closeConnection(String txId, Connection connection) {
try {
if (connection == null || connection.isClosed()) {
return;
}
} catch (Throwable t) {
LOGGER.debug(t, "Cannot determine DB connection status for transaction {0}", txId);
return;
}
try {
connection.close();
} catch (Throwable t) {
LOGGER.warn(RelationalProviderI18n.warnCannotCloseConnection, config.tableName(), txId, t.getMessage());
// log the full stack trace via debug
LOGGER.debug(t, "Cannot close connection");
}
}
@Override
public List<String> keys() {
//first read everything from the db
List<String> persistedKeys = runWithConnection(statements::getAllIds, true);
if (!TransactionsHolder.hasActiveTransaction()) {
// there is no active tx for just return the persistent view
return persistedKeys;
}
// there is an active transaction, so just filter out the keys which have been removed
persistedKeys.addAll(transactionalCaches.documentKeys());
return persistedKeys.stream().filter(id -> !transactionalCaches.isRemoved(id)).collect(Collectors.toList());
}
@Override
public Document get(String key) {
if (!TransactionsHolder.hasActiveTransaction()) {
// there is no active tx, so use a local read-only connection
return runWithConnection(connection -> statements.getById(connection, key), true);
}
// there is an active transaction so:
// search for the document in the cache
Document cachedDocument = transactionalCaches.search(key);
// if we found a cached value, return either that or null if it has been removed
if (cachedDocument != null) {
logDebug("Getting {0} from cache; value {1}", key, cachedDocument);
return cachedDocument != TransactionalCaches.REMOVED ? cachedDocument : null;
} else if (transactionalCaches.isNew(key)) {
return null;
}
// if it's not in the cache, bring one from the DB using a TL connection
Document doc = runWithConnection(connection -> statements.getById(connection, key), false);
if (doc != null) {
// store for further reading...
transactionalCaches.putForReading(key, doc);
} else {
// mark the key as new
transactionalCaches.putNew(key);
}
return doc;
}
@Override
public List<SchematicEntry> load(Collection<String> keys) {
List<SchematicEntry> alreadyChangedInTransaction = Collections.emptyList();
List<String> alreadyChangedKeys = new ArrayList<>();
if (TransactionsHolder.hasActiveTransaction()) {
// there's an active transaction so we want to look at stuff which we've already written in this tx and if there
// is anything, use it
alreadyChangedInTransaction = keys.stream()
.map(transactionalCaches::getForWriting)
.filter(Objects::nonNull)
.map(SchematicEntry::fromDocument)
.collect(ArrayList::new, (list, schematicEntry) -> {
alreadyChangedKeys.add(schematicEntry.id());
if (TransactionalCaches.REMOVED != schematicEntry.source()) {
list.add(schematicEntry);
}
}, ArrayList::addAll);
}
keys.removeAll(alreadyChangedKeys);
Function<Document, SchematicEntry> documentParser = document -> {
SchematicEntry entry = SchematicEntry.fromDocument(document);
String id = entry.id();
//always cache it to mark it as "existing"
transactionalCaches.putForReading(id, document);
return entry;
};
List<SchematicEntry> results = runWithConnection(connection -> statements.load(connection, keys, documentParser), true);
results.addAll(alreadyChangedInTransaction);
// if there's an active transaction make sure we also mark all the keys which were not found in the DB as 'new'
// to prevent further DB lookups
transactionalCaches.putNew(keys);
return results;
}
@Override
public boolean lockForWriting( List<String> locks ) {
if (locks.isEmpty()) {
return false;
}
TransactionsHolder.requireActiveTransaction();
return runWithConnection(connection -> statements.lockForWriting(connection, locks), true);
}
@Override
public void put(String key, SchematicEntry entry) {
// simply store the put into the cache
transactionalCaches.putForWriting(key, entry.source());
}
@Override
public EditableDocument editContent(String key, boolean createIfMissing) {
SchematicEntry entry = getEntry(key);
if (entry == null) {
if (createIfMissing) {
put(key, SchematicEntry.create(key));
} else {
return null;
}
}
// look for an entry which was set for writing
Document entryDocument = transactionalCaches.getForWriting(key);
if (entryDocument == null) {
// it's the first time we're editing this document as part of this tx so store this document for writing...
entryDocument = transactionalCaches.putForWriting(key, entry.source());
}
return SchematicEntry.content(entryDocument).editable();
}
@Override
public SchematicEntry putIfAbsent(String key, Document content) {
SchematicEntry existingEntry = getEntry(key);
if (existingEntry != null) {
return existingEntry;
} else {
put(key, SchematicEntry.create(key, content));
return null;
}
}
@Override
public boolean remove(String key) {
transactionalCaches.remove(key);
return true;
}
@Override
public void removeAll() {
runWithConnection(statements::removeAll, false);
}
@Override
public boolean containsKey(String key) {
if (!TransactionsHolder.hasActiveTransaction()) {
// if there is no active tx, just search the DB directly
return runWithConnection(connection -> statements.exists(connection, key), true);
}
// else look first in the caches for any transient / changed state
Document cachedDocument = transactionalCaches.search(key);
if (cachedDocument != null) {
// if it's in the cache, just return based on the cached info
return cachedDocument != TransactionalCaches.REMOVED;
} else if (transactionalCaches.isNew(key)) {
return false;
}
// otherwise it's not in the cache, so look in the DB
boolean existsInDB = runWithConnection(connection -> statements.exists(connection, key), true);
if (!existsInDB) {
// it's not in the DB, so mark it as such
transactionalCaches.putNew(key);
}
return existsInDB;
}
@Override
public void txStarted(String id) {
logDebug("New transaction '{0}' started by ModeShape...", id);
String activeTx = TransactionsHolder.activeTransaction();
if (activeTx != null && !activeTx.equals(id)) {
LOGGER.warn(RelationalProviderI18n.threadAssociatedWithAnotherTransaction,
Thread.currentThread().getName(), activeTx, id);
}
// mark the current thread as linked to a tx...
TransactionsHolder.setActiveTxId(id);
// and allocate a new connection for this transaction preemptively to isolate it from other connections
connectionForActiveTx();
}
@Override
public void txCommitted(String id) {
logDebug("Received committed notification for transaction '{0}'", id);
try {
Connection connection = connectionsByTxId.get(id);
persistContent(connection, id);
} catch (SQLException e) {
throw new RelationalProviderException(e);
} finally {
cleanupTransaction(id);
}
}
private void cleanupTransaction(String id) {
try {
// release any existing connection for this thread because a transaction has been committed...
connectionsByTxId.computeIfPresent(id, (txId, connection) -> {
closeConnection(txId, connection);
logDebug("Released DB connection for transaction '{0}'", id);
return null;
});
} finally {
// clear the tx cache
transactionalCaches.clearCache(id);
// and clear the tx
TransactionsHolder.clearActiveTransaction();
}
}
private void persistContent(Connection tlConnection, String txId) throws SQLException {
TransactionalCaches.TransactionalCache cache = transactionalCaches.cacheForTransaction(txId);
if (cache == null) {
// simply commit the connection
tlConnection.commit();
return;
}
Map<String, Document> writeCache = cache.writeCache();
Map<String, Document> readCache = cache.readCache();
logDebug("Committing the active connection for transaction {0} with the changes: {1}", txId, writeCache);
Statements.BatchUpdate batchUpdate = statements.batchUpdate(tlConnection);
Map<String, Document> toInsert = new HashMap<>();
Map<String, Document> toUpdate = new HashMap<>();
List<String> toRemove = new ArrayList<>();
writeCache.forEach(( key, document ) -> {
if (TransactionalCaches.REMOVED == document) {
toRemove.add(key);
} else if (readCache.containsKey(key)) {
toUpdate.put(key, document);
} else {
toInsert.put(key, document);
}
});
try {
batchUpdate.insert(toInsert);
batchUpdate.update(toUpdate);
batchUpdate.remove(toRemove);
} catch (SQLException e) {
throw new RelationalProviderException(e);
}
tlConnection.commit();
}
@Override
public void txRolledback(String id) {
logDebug("Received rollback notification for transaction '{0}'", id);
try {
runWithConnection(this::rollback, false);
} finally {
cleanupTransaction(id);
}
}
private Void rollback(Connection connection) throws SQLException {
connection.rollback();
return null;
}
protected <R> R runWithConnection(SQLFunction<R> function, boolean readonly) {
try {
if (TransactionsHolder.hasActiveTransaction()) {
// don't autoclose...
Connection connection = connectionForActiveTx();
return function.execute(connection);
}
// always autoclose
try (Connection connection = newConnection(true, readonly)) {
return function.execute(connection);
}
} catch (SQLException e) {
throw new RelationalProviderException(e);
}
}
protected Connection connectionForActiveTx() {
String activeTxId = TransactionsHolder.requireActiveTransaction();
Connection connection = connectionsByTxId.get(activeTxId);
if (connection != null) {
return connection;
}
connection = dsManager.newConnection(false, false);
connectionsByTxId.put(activeTxId, connection);
logDebug("New DB connection allocated for tx '{0}'", activeTxId);
return connection;
}
protected RelationalDbConfig config() {
return config;
}
protected DataSourceManager dsManager() {
return dsManager;
}
protected Connection newConnection(boolean autoCommit, boolean readonly) {
return dsManager.newConnection(autoCommit, readonly);
}
private Map<String, String> loadStatementsResource() {
try (InputStream fileStream = loadStatementsFile(dsManager.dbType())) {
Properties statements = new Properties();
statements.load(fileStream);
return statements.entrySet().stream().collect(Collectors.toMap(entry -> entry.getKey().toString(),
entry -> {
String value = entry.getValue().toString();
return !value.contains("{0}") ?
value :
StringUtil.createString(value,
config.tableName());
}));
} catch (IOException e) {
throw new RelationalProviderException(e);
}
}
private InputStream loadStatementsFile( DatabaseType dbType ) {
String filePrefix = RelationalDb.class.getPackage().getName().replaceAll("\\.", "/") + "/" + dbType.nameString().toLowerCase();
// first search for a file matching the major.minor version....
String majorMinorFile = filePrefix + String.format("_%s.%s_database.properties", dbType.majorVersion(), dbType.minorVersion());
// then a file matching just major version
String majorFile = filePrefix + String.format("_%s_database.properties", dbType.majorVersion());
// the a default with just the db name
String defaultFile = filePrefix + "_database.properties";
return Stream.of(majorMinorFile, majorFile, defaultFile)
.map(fileName -> {
InputStream is = RelationalDb.class.getClassLoader().getResourceAsStream(fileName);
if (LOGGER.isDebugEnabled()) {
if (is != null) {
LOGGER.debug("located DB statements file '{0}'", fileName);
} else {
LOGGER.debug("'{0}' statements file not found", fileName);
}
}
return is;
})
.filter(Objects::nonNull)
.findFirst()
.orElseThrow(() -> new RelationalProviderException(RelationalProviderI18n.unsupportedDBError, dbType));
}
@Override
public String toString() {
return "RelationalDB[" + config.toString() + "]";
}
private void logDebug(String message, Object...args) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(message, args);
}
}
@FunctionalInterface
private interface SQLFunction<R> {
R execute(Connection connection) throws SQLException;
}
}