/*
* Copyright 2009 The JA-SIG Collaborative. All rights reserved. See license
* distributed with this file and available online at
* http://www.ja-sig.org/products/cas/overview/license/
*/
package org.jasig.cas.ticket.registry.support;
import java.sql.Timestamp;
import java.util.Calendar;
import javax.sql.DataSource;
import javax.validation.constraints.NotNull;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.SqlRowSetResultSetExtractor;
import org.springframework.jdbc.support.rowset.SqlRowSet;
import org.springframework.transaction.annotation.Transactional;
/**
* Locking strategy that uses database storage for lock state that has the
* following properties:
* <ul>
* <li><strong>Exclusivity</strong> - Only only client at a time may acquire the lock.</li>
* <li><strong>Non-reentrant</strong> - An attempt to re-acquire the lock by the current
* holder will fail.</li>
* <li><strong>Lock expiration</strong> - Locks are acquired with an expiration such that
* a request to acquire the lock after the expiration date will succeed even
* if it is currently held by another client.</li>
* </ul>
* <p>
* This class requires a backing database table to exist based on the
* following template:
* <pre>
* CREATE TABLE LOCKS (
* APPLICATION_ID VARCHAR(50) NOT NULL,
* UNIQUE_ID VARCHAR(50) NULL,
* EXPIRATION_DATE TIMESTAMP NULL
* );
* ALTER TABLE LOCKS ADD CONSTRAINT LOCKS_PK
* PRIMARY KEY (APPLICATION_ID);
* </pre>
* </p>
* <p>
* Note that table and column names can be controlled through instance
* properties, but the create table script above is consistent with defaults.
* </p>
*
* @author Marvin S. Addison
* @version $Revision: 19533 $
* @since 3.3.6
*
*/
public class JdbcLockingStrategy
implements LockingStrategy, InitializingBean {
/** Default lock timeout is 1 hour */
public static final int DEFAULT_LOCK_TIMEOUT = 3600;
/** Default database platform is SQL-92 */
private static final DatabasePlatform DEFAULT_PLATFORM =
DatabasePlatform.SQL92;
/** Default locking table name is LOCKS */
private static final String DEFAULT_TABLE_NAME = "LOCKS";
/** Default unique identifier column name is UNIQUE_ID */
private static final String UNIQUE_ID_COLUMN_NAME = "UNIQUE_ID";
/** Default application identifier column name is APPLICATION_ID */
private static final String APPLICATION_ID_COLUMN_NAME = "APPLICATION_ID";
/** Default expiration date column name is EXPIRATION_DATE */
private static final String EXPIRATION_DATE_COLUMN_NAME = "EXPIRATION_DATE";
/** Database table name that stores locks */
@NotNull
private String tableName = DEFAULT_TABLE_NAME;
/** Database column name that holds unique identifier */
@NotNull
private String uniqueIdColumnName = UNIQUE_ID_COLUMN_NAME;
/** Database column name that holds application identifier */
@NotNull
private String applicationIdColumnName = APPLICATION_ID_COLUMN_NAME;
/** Database column name that holds expiration date */
@NotNull
private String expirationDateColumnName = EXPIRATION_DATE_COLUMN_NAME;
/** Unique identifier that identifies the client using this lock instance */
@NotNull
private String uniqueId;
/**
* Application identifier that identifies rows in the locking table,
* each one of which may be for a different application or usage within
* a single application.
*/
@NotNull
private String applicationId;
/** Amount of time in seconds lock may be held */
private int lockTimeout = DEFAULT_LOCK_TIMEOUT;
/** JDBC data source */
@NotNull
private DataSource dataSource;
/** Database platform */
@NotNull
private DatabasePlatform platform = DEFAULT_PLATFORM;
/** Spring JDBC template used to execute SQL statements */
private JdbcTemplate jdbcTemplate;
/** SQL statement for selecting a lock */
private String selectSql;
/** SQL statement for creating a lock for a given application ID */
private String createSql;
/** SQL statement for updating a lock to acquired state */
private String updateAcquireSql;
/** SQL statement for updating a lock to released state */
private String updateReleaseSql;
/**
* Supported database platforms provides support for platform-specific
* behavior such as locking semantics.
*/
public enum DatabasePlatform {
/**
* Any platform that supports the SQL-92 FOR UPDATE updatability clause
* for SELECT queries and the related exclusive row locking
* semantics it suggests.
*/
SQL92,
/** HSQLDB platform */
HSQL,
/** Microsoft SQL Server platform */
SqlServer;
}
/**
* @param id Identifier used to identify this instance in a row of the
* lock table. Must be unique across all clients vying for
* locks for a given application ID.
*/
public void setUniqueId(final String id) {
this.uniqueId = id;
}
/**
* @param id Application identifier that identifies a row in the lock
* table for which multiple clients vie to hold the lock.
*/
public void setApplicationId(final String id) {
this.applicationId = id;
}
/**
* @param seconds Maximum amount of time in seconds lock may be held.
*/
public void setLockTimeout(final int seconds) {
this.lockTimeout = seconds;
}
/**
* @param name Name of database table holding locks.
*/
public void setTableName(final String name) {
this.tableName = name;
}
/**
* @param name Name of database column that stores application ID.
*/
public void setApplicationIdColumnName(final String name) {
this.applicationIdColumnName = name;
}
/**
* @param name Name of database column that stores unique ID.
*/
public void setUniqueIdColumnName(final String name) {
this.uniqueIdColumnName = name;
}
/**
* @param name Name of database column that stores lock expiration date.
*/
public void setExpirationDateColumnName(final String name) {
this.expirationDateColumnName = name;
}
/**
* @param dataSource JDBC data source.
*/
public void setDataSource(final DataSource dataSource) {
this.dataSource = dataSource;
}
/**
* @param platform Database platform that indicates when special syntax
* is needed for database operations.
*/
public void setPlatform(final DatabasePlatform platform) {
this.platform = platform;
}
/** {@inheritDoc} */
public void afterPropertiesSet() {
this.jdbcTemplate = new JdbcTemplate(this.dataSource);
this.jdbcTemplate.afterPropertiesSet();
this.createSql = String.format(
"INSERT INTO %s (%s, %s, %s) VALUES(?, ?, ?)",
this.tableName,
this.applicationIdColumnName,
this.uniqueIdColumnName,
this.expirationDateColumnName);
this.updateAcquireSql = String.format(
"UPDATE %s SET %s=?, %s=? WHERE %s=?",
this.tableName,
this.uniqueIdColumnName,
this.expirationDateColumnName,
this.applicationIdColumnName);
this.updateReleaseSql = String.format(
"UPDATE %s SET %s=NULL, %s=NULL WHERE %s=? AND %s=?",
this.tableName,
this.uniqueIdColumnName,
this.expirationDateColumnName,
this.applicationIdColumnName,
this.uniqueIdColumnName);
// Support platform-specific syntax for select query
final StringBuilder sb = new StringBuilder();
sb.append(String.format("SELECT %s, %s FROM %s WHERE %s=?",
this.uniqueIdColumnName,
this.expirationDateColumnName,
this.tableName,
this.applicationIdColumnName));
switch (this.platform) {
case HSQL:
case SqlServer:
// Neither HSQL nor SQL Server support FOR UPDATE
break;
default:
// SQL-92 compliant platforms support FOR UPDATE updatability clause
sb.append(" FOR UPDATE");
break;
}
this.selectSql = sb.toString();
}
/**
* @see org.jasig.cas.ticket.registry.support.LockingStrategy#acquire()
*/
@Transactional
public boolean acquire() {
boolean lockAcquired = false;
if (this.platform == DatabasePlatform.SqlServer) {
this.jdbcTemplate.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE");
}
try {
final SqlRowSet rowSet = (SqlRowSet) this.jdbcTemplate.query(
this.selectSql,
new Object[] {this.applicationId},
new SqlRowSetResultSetExtractor());
final Timestamp expDate = getExpirationDate();
if (!rowSet.next()) {
// No row exists for this applicationId so create it.
// Row is created with uniqueId of this instance
// which indicates the lock is initially held by this instance.
this.jdbcTemplate.update(this.createSql, new Object[] {this.applicationId, this.uniqueId, expDate});
return true;
}
lockAcquired = canAcquire(rowSet);
if (lockAcquired) {
// Update unique ID of row to indicate this instance holds lock
this.jdbcTemplate.update(this.updateAcquireSql, new Object[] {this.uniqueId, expDate, this.applicationId});
}
} finally {
// Always attempt to revert current connection to default isolation
// level on SQL Server
if (this.platform == DatabasePlatform.SqlServer) {
this.jdbcTemplate.execute("SET TRANSACTION ISOLATION LEVEL READ COMMITTED");
}
}
return lockAcquired;
}
/**
* @see org.jasig.cas.ticket.registry.support.LockingStrategy#release()
*/
@Transactional
public void release() {
// Update unique ID of row to indicate this instance holds lock
this.jdbcTemplate.update(this.updateReleaseSql, new Object[] {this.applicationId, this.uniqueId});
}
/**
* Determines whether this instance can acquire the lock.
*
* @param lockRow Row of lock data for this application ID.
*
* @return True if lock can be acquired, false otherwise.
*/
private boolean canAcquire(final SqlRowSet lockRow) {
if (lockRow.getString(this.uniqueIdColumnName) != null) {
final Calendar expCal = Calendar.getInstance();
expCal.setTime(lockRow.getTimestamp(this.expirationDateColumnName));
return Calendar.getInstance().after(expCal);
}
return true;
}
/**
* @return The expiration date for a lock acquired at the current system
* time
*/
private Timestamp getExpirationDate() {
final Calendar cal = Calendar.getInstance();
cal.add(Calendar.SECOND, this.lockTimeout);
return new Timestamp(cal.getTimeInMillis());
}
}