/*
* 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.journal;
import org.apache.commons.io.IOUtils;
import org.apache.jackrabbit.core.util.db.CheckSchemaOperation;
import org.apache.jackrabbit.core.util.db.ConnectionFactory;
import org.apache.jackrabbit.core.util.db.ConnectionHelper;
import org.apache.jackrabbit.core.util.db.DatabaseAware;
import org.apache.jackrabbit.core.util.db.DbUtility;
import org.apache.jackrabbit.core.util.db.StreamWrapper;
import org.apache.jackrabbit.spi.commons.namespace.NamespaceResolver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Calendar;
import javax.jcr.RepositoryException;
import javax.sql.DataSource;
/**
* Database-based journal implementation. Stores records inside a database table named
* <code>JOURNAL</code>, whereas the table <code>GLOBAL_REVISION</code> contains the
* highest available revision number. These tables are located inside the schema specified
* in <code>schemaObjectPrefix</code>.
* <p>
* It is configured through the following properties:
* <ul>
* <li><code>driver</code>: the JDBC driver class name to use; this is a required
* property with no default value</li>
* <li><code>url</code>: the JDBC connection url; this is a required property with
* no default value </li>
* <li><code>databaseType</code>: the database type to be used; if not specified, this is the
* second field inside the JDBC connection url, delimited by colons</li>
* <li><code>schemaObjectPrefix</code>: the schema object prefix to be used;
* defaults to an empty string</li>
* <li><code>user</code>: username to specify when connecting</li>
* <li><code>password</code>: password to specify when connecting</li>
* <li><code>reconnectDelayMs</code>: number of milliseconds to wait before
* trying to reconnect to the database.</li>
* <li><code>janitorEnabled</code>: specifies whether the clean-up thread for the
* journal table is enabled (default = <code>false</code>)</li>
* <li><code>janitorSleep</code>: specifies the sleep time of the clean-up thread
* in seconds (only useful when the clean-up thread is enabled, default = 24 * 60 * 60,
* which equals 24 hours)</li>
* <li><code>janitorFirstRunHourOfDay</code>: specifies the hour at which the clean-up
* thread initiates its first run (default = <code>3</code> which means 3:00 at night)</li>
* <li><code>schemaCheckEnabled</code>: whether the schema check during initialization is enabled
* (default = <code>true</code>)</li>
* </ul>
* <p>
* JNDI can be used to get the connection. In this case, use the javax.naming.InitialContext as the driver,
* and the JNDI name as the URL. If the user and password are configured in the JNDI resource,
* they should not be configured here. Example JNDI settings:
* <pre>
* <param name="driver" value="javax.naming.InitialContext" />
* <param name="url" value="java:comp/env/jdbc/Test" />
* </pre>
*/
public class DatabaseJournal extends AbstractJournal implements DatabaseAware {
/**
* Default journal table name, used to check schema completeness.
*/
private static final String DEFAULT_JOURNAL_TABLE = "JOURNAL";
/**
* Local revisions table name, used to check schema completeness.
*/
private static final String LOCAL_REVISIONS_TABLE = "LOCAL_REVISIONS";
/**
* Logger.
*/
static Logger log = LoggerFactory.getLogger(DatabaseJournal.class);
/**
* Driver name, bean property.
*/
private String driver;
/**
* Connection URL, bean property.
*/
private String url;
/**
* Database type, bean property.
*/
private String databaseType;
/**
* User name, bean property.
*/
private String user;
/**
* Password, bean property.
*/
private String password;
/**
* DataSource logical name, bean property.
*/
private String dataSourceName;
/**
* The connection helper
*/
ConnectionHelper conHelper;
/**
* Auto commit level.
*/
private int lockLevel;
/**
* Locked revision.
*/
private long lockedRevision;
/**
* Whether the revision table janitor thread is enabled.
*/
private boolean janitorEnabled = false;
/**
* The sleep time of the revision table janitor in seconds, 1 day default.
*/
int janitorSleep = 60 * 60 * 24;
/**
* Indicates when the next run of the janitor is scheduled.
* The first run is scheduled by default at 03:00 hours.
*/
Calendar janitorNextRun = Calendar.getInstance();
{
if (janitorNextRun.get(Calendar.HOUR_OF_DAY) >= 3) {
janitorNextRun.add(Calendar.DAY_OF_MONTH, 1);
}
janitorNextRun.set(Calendar.HOUR_OF_DAY, 3);
janitorNextRun.set(Calendar.MINUTE, 0);
janitorNextRun.set(Calendar.SECOND, 0);
janitorNextRun.set(Calendar.MILLISECOND, 0);
}
private Thread janitorThread;
/**
* Whether the schema check must be done during initialization.
*/
private boolean schemaCheckEnabled = true;
/**
* The instance that manages the local revision.
*/
private DatabaseRevision databaseRevision;
/**
* SQL statement returning all revisions within a range.
*/
protected String selectRevisionsStmtSQL;
/**
* SQL statement updating the global revision.
*/
protected String updateGlobalStmtSQL;
/**
* SQL statement returning the global revision.
*/
protected String selectGlobalStmtSQL;
/**
* SQL statement appending a new record.
*/
protected String insertRevisionStmtSQL;
/**
* SQL statement returning the minimum of the local revisions.
*/
protected String selectMinLocalRevisionStmtSQL;
/**
* SQL statement removing a set of revisions with from the journal table.
*/
protected String cleanRevisionStmtSQL;
/**
* SQL statement returning the local revision of this cluster node.
*/
protected String getLocalRevisionStmtSQL;
/**
* SQL statement for inserting the local revision of this cluster node.
*/
protected String insertLocalRevisionStmtSQL;
/**
* SQL statement for updating the local revision of this cluster node.
*/
protected String updateLocalRevisionStmtSQL;
/**
* Schema object prefix, bean property.
*/
protected String schemaObjectPrefix;
/**
* The repositories {@link ConnectionFactory}.
*/
private ConnectionFactory connectionFactory;
public DatabaseJournal() {
databaseType = "default";
schemaObjectPrefix = "";
}
/**
* {@inheritDoc}
*/
public void setConnectionFactory(ConnectionFactory connnectionFactory) {
this.connectionFactory = connnectionFactory;
}
/**
* {@inheritDoc}
*/
public void init(String id, NamespaceResolver resolver)
throws JournalException {
super.init(id, resolver);
init();
try {
conHelper = createConnectionHelper(getDataSource());
// make sure schemaObjectPrefix consists of legal name characters only
schemaObjectPrefix = conHelper.prepareDbIdentifier(schemaObjectPrefix);
// check if schema objects exist and create them if necessary
if (isSchemaCheckEnabled()) {
createCheckSchemaOperation().run();
}
// Make sure that the LOCAL_REVISIONS table exists (see JCR-1087)
if (isSchemaCheckEnabled()) {
checkLocalRevisionSchema();
}
buildSQLStatements();
initInstanceRevisionAndJanitor();
} catch (Exception e) {
String msg = "Unable to create connection.";
throw new JournalException(msg, e);
}
log.info("DatabaseJournal initialized.");
}
private DataSource getDataSource() throws Exception {
if (getDataSourceName() == null || "".equals(getDataSourceName())) {
return connectionFactory.getDataSource(getDriver(), getUrl(), getUser(), getPassword());
} else {
return connectionFactory.getDataSource(dataSourceName);
}
}
/**
* This method is called from the {@link #init(String, NamespaceResolver)} method of this class and
* returns a {@link ConnectionHelper} instance which is assigned to the {@code conHelper} field.
* Subclasses may override it to return a specialized connection helper.
*
* @param dataSrc the {@link DataSource} of this persistence manager
* @return a {@link ConnectionHelper}
* @throws Exception on error
*/
protected ConnectionHelper createConnectionHelper(DataSource dataSrc) throws Exception {
return new ConnectionHelper(dataSrc, false);
}
/**
* This method is called from {@link #init(String, NamespaceResolver)} after the
* {@link #createConnectionHelper(DataSource)} method, and returns a default {@link CheckSchemaOperation}.
* Subclasses can override this implementation to get a customized implementation.
*
* @return a new {@link CheckSchemaOperation} instance
*/
protected CheckSchemaOperation createCheckSchemaOperation() {
InputStream in = DatabaseJournal.class.getResourceAsStream(databaseType + ".ddl");
return new CheckSchemaOperation(conHelper, in, schemaObjectPrefix + DEFAULT_JOURNAL_TABLE).addVariableReplacement(
CheckSchemaOperation.SCHEMA_OBJECT_PREFIX_VARIABLE, schemaObjectPrefix);
}
/**
* Completes initialization of this database journal. Base implementation
* checks whether the required bean properties <code>driver</code> and
* <code>url</code> have been specified and optionally deduces a valid
* database type. Should be overridden by subclasses that use a different way to
* create a connection and therefore require other arguments.
*
* @throws JournalException if initialization fails
*/
protected void init() throws JournalException {
if (driver == null && dataSourceName == null) {
String msg = "Driver not specified.";
throw new JournalException(msg);
}
if (url == null && dataSourceName == null) {
String msg = "Connection URL not specified.";
throw new JournalException(msg);
}
if (dataSourceName != null) {
try {
String configuredDatabaseType = connectionFactory.getDataBaseType(dataSourceName);
if (DatabaseJournal.class.getResourceAsStream(configuredDatabaseType + ".ddl") != null) {
setDatabaseType(configuredDatabaseType);
}
} catch (RepositoryException e) {
throw new JournalException("failed to get database type", e);
}
}
if (databaseType == null) {
try {
databaseType = getDatabaseTypeFromURL(url);
} catch (IllegalArgumentException e) {
String msg = "Unable to derive database type from URL: " + e.getMessage();
throw new JournalException(msg);
}
}
}
/**
* Initialize the instance revision manager and the janitor thread.
*
* @throws JournalException on error
*/
protected void initInstanceRevisionAndJanitor() throws Exception {
databaseRevision = new DatabaseRevision();
// Get the local file revision from disk (upgrade; see JCR-1087)
long localFileRevision = 0L;
if (getRevision() != null) {
InstanceRevision currentFileRevision = new FileRevision(new File(getRevision()), true);
localFileRevision = currentFileRevision.get();
currentFileRevision.close();
}
// Now write the localFileRevision (or 0 if it does not exist) to the LOCAL_REVISIONS
// table, but only if the LOCAL_REVISIONS table has no entry yet for this cluster node
long localRevision = databaseRevision.init(localFileRevision);
log.info("Initialized local revision to " + localRevision);
// Start the clean-up thread if necessary.
if (janitorEnabled) {
janitorThread = new Thread(new RevisionTableJanitor(), "Jackrabbit-ClusterRevisionJanitor");
janitorThread.setDaemon(true);
janitorThread.start();
log.info("Cluster revision janitor thread started; first run scheduled at " + janitorNextRun.getTime());
} else {
log.info("Cluster revision janitor thread not started");
}
}
/* (non-Javadoc)
* @see org.apache.jackrabbit.core.journal.Journal#getInstanceRevision()
*/
public InstanceRevision getInstanceRevision() throws JournalException {
return databaseRevision;
}
/**
* Derive a database type from a JDBC connection URL. This simply treats the given URL
* as delimeted by colons and takes the 2nd field.
*
* @param url JDBC connection URL
* @return the database type
* @throws IllegalArgumentException if the JDBC connection URL is invalid
*/
private static String getDatabaseTypeFromURL(String url) throws IllegalArgumentException {
int start = url.indexOf(':');
if (start != -1) {
int end = url.indexOf(':', start + 1);
if (end != -1) {
return url.substring(start + 1, end);
}
}
throw new IllegalArgumentException(url);
}
/**
* {@inheritDoc}
*/
public RecordIterator getRecords(long startRevision) throws JournalException {
try {
return new DatabaseRecordIterator(conHelper.exec(selectRevisionsStmtSQL, new Object[]{new Long(
startRevision)}, false, 0), getResolver(), getNamePathResolver());
} catch (SQLException e) {
throw new JournalException("Unable to return record iterator.", e);
}
}
/**
* {@inheritDoc}
*/
public RecordIterator getRecords() throws JournalException {
try {
return new DatabaseRecordIterator(conHelper.exec(selectRevisionsStmtSQL, new Object[]{new Long(
Long.MIN_VALUE)}, false, 0), getResolver(), getNamePathResolver());
} catch (SQLException e) {
throw new JournalException("Unable to return record iterator.", e);
}
}
/**
* Synchronize contents from journal. May be overridden by subclasses.
* Do the initial sync in batchMode, since some databases (PSQL) when
* not in transactional mode, load all results in memory which causes
* out of memory. See JCR-2832
*
* @param startRevision start point (exclusive)
* @param startup indicates if the cluster node is syncing on startup
* or does a normal sync.
* @throws JournalException if an error occurs
*/
@Override
protected void doSync(long startRevision, boolean startup) throws JournalException {
if (!startup) {
// if the cluster node is not starting do a normal sync
doSync(startRevision);
} else {
try {
startBatch();
try {
doSync(startRevision);
} finally {
endBatch(true);
}
} catch (SQLException e) {
throw new JournalException("Couldn't sync the cluster node", e);
}
}
}
/**
* {@inheritDoc}
* <p>
* This journal is locked by incrementing the current value in the table
* named <code>GLOBAL_REVISION</code>, which effectively write-locks this
* table. The updated value is then saved away and remembered in the
* appended record, because a save may entail multiple appends (JCR-884).
*/
protected void doLock() throws JournalException {
ResultSet rs = null;
boolean succeeded = false;
try {
startBatch();
} catch (SQLException e) {
throw new JournalException("Unable to set autocommit to false.", e);
}
try {
conHelper.exec(updateGlobalStmtSQL);
rs = conHelper.exec(selectGlobalStmtSQL, null, false, 0);
if (!rs.next()) {
throw new JournalException("No revision available.");
}
lockedRevision = rs.getLong(1);
succeeded = true;
} catch (SQLException e) {
throw new JournalException("Unable to lock global revision table.", e);
} finally {
DbUtility.close(rs);
if (!succeeded) {
doUnlock(false);
}
}
}
/**
* {@inheritDoc}
*/
protected void doUnlock(boolean successful) {
endBatch(successful);
}
private void startBatch() throws SQLException {
if (lockLevel++ == 0) {
conHelper.startBatch();
}
}
private void endBatch(boolean successful) {
if (--lockLevel == 0) {
try {
conHelper.endBatch(successful);;
} catch (SQLException e) {
log.error("failed to end batch", e);
}
}
}
/**
* {@inheritDoc}
* <p>
* Save away the locked revision inside the newly appended record.
*/
protected void appending(AppendRecord record) {
record.setRevision(lockedRevision);
}
/**
* {@inheritDoc}
* <p>
* We have already saved away the revision for this record.
*/
protected void append(AppendRecord record, InputStream in, int length)
throws JournalException {
try {
conHelper.exec(insertRevisionStmtSQL, record.getRevision(), getId(), record.getProducerId(),
new StreamWrapper(in, length));
} catch (SQLException e) {
String msg = "Unable to append revision " + lockedRevision + ".";
throw new JournalException(msg, e);
}
}
/**
* {@inheritDoc}
*/
public void close() {
if (janitorThread != null) {
janitorThread.interrupt();
}
}
/**
* Checks if the local revision schema objects exist and creates them if they
* don't exist yet.
*
* @throws Exception if an error occurs
*/
private void checkLocalRevisionSchema() throws Exception {
InputStream localRevisionDDLStream = null;
InputStream in = DatabaseJournal.class.getResourceAsStream(databaseType + ".ddl");
try {
BufferedReader reader = new BufferedReader(new InputStreamReader(in));
String sql = reader.readLine();
while (sql != null) {
// Skip comments and empty lines, and select only the statement to create the LOCAL_REVISIONS
// table.
if (!sql.startsWith("#") && sql.length() > 0 && sql.indexOf(LOCAL_REVISIONS_TABLE) != -1) {
localRevisionDDLStream = new ByteArrayInputStream(sql.getBytes());
break;
}
// read next sql stmt
sql = reader.readLine();
}
} finally {
IOUtils.closeQuietly(in);
}
// Run the schema check for the single table
new CheckSchemaOperation(conHelper, localRevisionDDLStream, schemaObjectPrefix
+ LOCAL_REVISIONS_TABLE).addVariableReplacement(
CheckSchemaOperation.SCHEMA_OBJECT_PREFIX_VARIABLE, schemaObjectPrefix).run();
}
/**
* Builds the SQL statements. May be overridden by subclasses to allow
* different table and/or column names.
*/
protected void buildSQLStatements() {
selectRevisionsStmtSQL =
"select REVISION_ID, JOURNAL_ID, PRODUCER_ID, REVISION_DATA from "
+ schemaObjectPrefix + "JOURNAL where REVISION_ID > ? order by REVISION_ID";
updateGlobalStmtSQL =
"update " + schemaObjectPrefix + "GLOBAL_REVISION"
+ " set REVISION_ID = REVISION_ID + 1";
selectGlobalStmtSQL =
"select REVISION_ID from "
+ schemaObjectPrefix + "GLOBAL_REVISION";
insertRevisionStmtSQL =
"insert into " + schemaObjectPrefix + "JOURNAL"
+ " (REVISION_ID, JOURNAL_ID, PRODUCER_ID, REVISION_DATA) "
+ "values (?,?,?,?)";
selectMinLocalRevisionStmtSQL =
"select MIN(REVISION_ID) from " + schemaObjectPrefix + "LOCAL_REVISIONS";
cleanRevisionStmtSQL =
"delete from " + schemaObjectPrefix + "JOURNAL " + "where REVISION_ID < ?";
getLocalRevisionStmtSQL =
"select REVISION_ID from " + schemaObjectPrefix + "LOCAL_REVISIONS "
+ "where JOURNAL_ID = ?";
insertLocalRevisionStmtSQL =
"insert into " + schemaObjectPrefix + "LOCAL_REVISIONS "
+ "(REVISION_ID, JOURNAL_ID) values (?,?)";
updateLocalRevisionStmtSQL =
"update " + schemaObjectPrefix + "LOCAL_REVISIONS "
+ "set REVISION_ID = ? where JOURNAL_ID = ?";
}
/**
* Bean getters
*/
public String getDriver() {
return driver;
}
public String getUrl() {
return url;
}
/**
* Get the database type.
*
* @return the database type
*/
public String getDatabaseType() {
return databaseType;
}
/**
* Get the database type.
* @deprecated
* This method is deprecated; {@link #getDatabaseType} should be used instead.
*
* @return the database type
*/
public String getSchema() {
return databaseType;
}
public String getSchemaObjectPrefix() {
return schemaObjectPrefix;
}
public String getUser() {
return user;
}
public String getPassword() {
return password;
}
public boolean getJanitorEnabled() {
return janitorEnabled;
}
public int getJanitorSleep() {
return janitorSleep;
}
public int getJanitorFirstRunHourOfDay() {
return janitorNextRun.get(Calendar.HOUR_OF_DAY);
}
/**
* Bean setters
*/
public void setDriver(String driver) {
this.driver = driver;
}
public void setUrl(String url) {
this.url = url;
}
/**
* Set the database type.
*
* @param databaseType the database type
*/
public void setDatabaseType(String databaseType) {
this.databaseType = databaseType;
}
/**
* Set the database type.
* @deprecated
* This method is deprecated; {@link #getDatabaseType} should be used instead.
*
* @param databaseType the database type
*/
public void setSchema(String databaseType) {
this.databaseType = databaseType;
}
public void setSchemaObjectPrefix(String schemaObjectPrefix) {
this.schemaObjectPrefix = schemaObjectPrefix.toUpperCase();
}
public void setUser(String user) {
this.user = user;
}
public void setPassword(String password) {
this.password = password;
}
public void setJanitorEnabled(boolean enabled) {
this.janitorEnabled = enabled;
}
public void setJanitorSleep(int sleep) {
this.janitorSleep = sleep;
}
public void setJanitorFirstRunHourOfDay(int hourOfDay) {
janitorNextRun = Calendar.getInstance();
if (janitorNextRun.get(Calendar.HOUR_OF_DAY) >= hourOfDay) {
janitorNextRun.add(Calendar.DAY_OF_MONTH, 1);
}
janitorNextRun.set(Calendar.HOUR_OF_DAY, hourOfDay);
janitorNextRun.set(Calendar.MINUTE, 0);
janitorNextRun.set(Calendar.SECOND, 0);
janitorNextRun.set(Calendar.MILLISECOND, 0);
}
public String getDataSourceName() {
return dataSourceName;
}
public void setDataSourceName(String dataSourceName) {
this.dataSourceName = dataSourceName;
}
/**
* @return whether the schema check is enabled
*/
public final boolean isSchemaCheckEnabled() {
return schemaCheckEnabled;
}
/**
* @param enabled set whether the schema check is enabled
*/
public final void setSchemaCheckEnabled(boolean enabled) {
schemaCheckEnabled = enabled;
}
/**
* This class manages the local revision of the cluster node. It
* persists the local revision in the LOCAL_REVISIONS table in the
* clustering database.
*/
public class DatabaseRevision implements InstanceRevision {
/**
* The cached local revision of this cluster node.
*/
private long localRevision;
/**
* Indicates whether the init method has been called.
*/
private boolean initialized = false;
/**
* Checks whether there's a local revision value in the database for this
* cluster node. If not, it writes the given default revision to the database.
*
* @param revision the default value for the local revision counter
* @return the local revision
* @throws JournalException on error
*/
protected synchronized long init(long revision) throws JournalException {
ResultSet rs = null;
try {
// Check whether there is an entry in the database.
rs = conHelper.exec(getLocalRevisionStmtSQL, new Object[]{getId()}, false, 0);
boolean exists = rs.next();
if (exists) {
revision = rs.getLong(1);
}
// Insert the given revision in the database
if (!exists) {
conHelper.exec(insertLocalRevisionStmtSQL, revision, getId());
}
// Set the cached local revision and return
localRevision = revision;
initialized = true;
return revision;
} catch (SQLException e) {
log.warn("Failed to initialize local revision.", e);
throw new JournalException("Failed to initialize local revision", e);
} finally {
DbUtility.close(rs);
}
}
public synchronized long get() {
if (!initialized) {
throw new IllegalStateException("instance has not yet been initialized");
}
return localRevision;
}
public synchronized void set(long localRevision) throws JournalException {
if (!initialized) {
throw new IllegalStateException("instance has not yet been initialized");
}
// Update the cached value and the table with local revisions.
try {
conHelper.exec(updateLocalRevisionStmtSQL, localRevision, getId());
this.localRevision = localRevision;
} catch (SQLException e) {
log.warn("Failed to update local revision.", e);
throw new JournalException("Failed to update local revision.", e);
}
}
public void close() {
// nothing to do
}
}
/**
* Class for maintaining the revision table. This is only useful if all
* JR information except the search index is in the database (i.e., node types
* etc). In that case, revision data can safely be thrown away from the JOURNAL table.
*/
public class RevisionTableJanitor implements Runnable {
/**
* {@inheritDoc}
*/
public void run() {
while (!Thread.currentThread().isInterrupted()) {
try {
log.info("Next clean-up run scheduled at " + janitorNextRun.getTime());
long sleepTime = janitorNextRun.getTimeInMillis() - System.currentTimeMillis();
if (sleepTime > 0) {
Thread.sleep(sleepTime);
}
cleanUpOldRevisions();
janitorNextRun.add(Calendar.SECOND, janitorSleep);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
log.info("Interrupted: stopping clean-up task.");
}
/**
* Cleans old revisions from the clustering table.
*/
protected void cleanUpOldRevisions() {
ResultSet rs = null;
try {
long minRevision = 0;
rs = conHelper.exec(selectMinLocalRevisionStmtSQL, null, false, 0);
boolean cleanUp = rs.next();
if (cleanUp) {
minRevision = rs.getLong(1);
}
// Clean up if necessary:
if (cleanUp) {
conHelper.exec(cleanRevisionStmtSQL, minRevision);
log.info("Cleaned old revisions up to revision " + minRevision + ".");
}
} catch (Exception e) {
log.warn("Failed to clean up old revisions.", e);
} finally {
DbUtility.close(rs);
}
}
}
}