/*
* Copyright � 2004, Rob Gordon.
*/
package org.oddjob.sql;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import javax.inject.Inject;
import org.apache.log4j.Logger;
import org.oddjob.schedules.IntervalTo;
import org.oddjob.schedules.Schedule;
import org.oddjob.schedules.ScheduleContext;
import org.oddjob.schedules.ScheduleResult;
import org.oddjob.schedules.schedules.IntervalSchedule;
import org.oddjob.scheduling.Keeper;
import org.oddjob.scheduling.LoosingOutcome;
import org.oddjob.scheduling.Outcome;
import org.oddjob.scheduling.WinningOutcome;
import org.oddjob.state.IsAnyState;
import org.oddjob.state.IsStoppable;
import org.oddjob.state.JobState;
import org.oddjob.state.StateEvent;
import org.oddjob.state.JobStateHandler;
import org.oddjob.state.StateListener;
/**
* @oddjob.description Provides a {@link Keeper} that uses a database
* table.
* <p>
* The keeper uses a simple 'first to insert' a row wins methodology for deciding
* winner and looser. This is quite primitive and decides that any exception
* from the insert operation is a duplicate key exception and therefore a
* looser.
* <p>
* A {@link LoosingOutcome} will continue to Poll the database (for as long
* as it has listeners) until the work is complete. The default polling schedule
* polls every 5 seconds indefinitely. The <code>pollSchedule</code> property
* can be used to poll for a limited time, after which it flags an exception
* state. This could be used by loosing servers to flag the winner is taking
* too long and has possibly crashed.
* <p>
* This is an example of the SQL that would create a suitable table.
*
* <pre><code>
* CREATE TABLE oddjob_grabbable(
* key VARCHAR(32),
* instance VARCHAR(32),
* winner VARCHAR(32),
* complete boolean,
* CONSTRAINT oddjob_pk PRIMARY KEY (key, instance))
* </pre></code>
*
* <p>
* This service does not tidy up the database so rows will grow indefinitely.
* A separate tidy job should be implemented.
*
* @oddjob.example
*
* See the User Guide.
*
* @author Rob Gordon.
*/
public class SQLKeeperService {
private static final Logger logger = Logger.getLogger(SQLKeeperService.class);
/** The default table name. */
public static final String TABLE_NAME = "oddjob_grabbable";
/**
* @oddjob.property
* @oddjob.description The name.
* @oddjob.required No.
*/
private String name;
/**
* @oddjob.property
* @oddjob.description The {@link ConnectionType} to use.
* @oddjob.required Yes.
*/
private Connection connection;
/**
* @oddjob.property
* @oddjob.description The database table name.
* @oddjob.required No.
*/
private String table;
/**
* @oddjob.property scheduleExecutorService
* @oddjob.description The scheduling service for polling.
* @oddjob.required No - provided by Oddjob.
*/
private ScheduledExecutorService scheduler;
/**
* @oddjob.property
* @oddjob.description The schedule to provide the polling interval.
* @oddjob.required No - defaults to a 5 second {@link IntervalSchedule}.
*/
private Schedule pollSchedule;
/** Flag to indicate service is running. */
private volatile boolean running;
/** Keep track of polling loosers so they may be stopped from polling. */
private final List<ALoosingOutcome> loosers =
new ArrayList<ALoosingOutcome>();
/**
* Set up a default poll schedule.
*/
{
pollSchedule = new IntervalSchedule(5000L);
}
/**
* Start the service.
*
* @throws SQLException
*/
public void start() throws SQLException {
if (connection == null) {
throw new NullPointerException("No Connection.");
}
if (table == null) {
table = TABLE_NAME;
}
running = true;
}
/**
* Stop the service.
*
* @throws SQLException
*/
public void stop() throws SQLException {
running = false;
while (true){
ALoosingOutcome looser = null;
synchronized (loosers) {
if (loosers.size() > 0) {
looser = loosers.remove(0);
}
}
if (looser == null) {
break;
}
looser.stop();
}
if (connection != null) {
connection.close();
}
}
/**
* Provide a {@link Keeper}.
*
* @param keeperKey The keepers key. Must not be null.
*
* @return A keeper. Never null.
*/
public Keeper getKeeper(final String keeperKey) {
if (keeperKey == null) {
throw new NullPointerException("No Identifier.");
}
return new Keeper() {
@Override
public Outcome grab(String ourIdentifier, Object instanceIdentifier) {
if (!running) {
throw new IllegalStateException(
"SQLKeeperService not Running.");
}
if (ourIdentifier == null) {
throw new NullPointerException(
"The Grabber Identifier must not be null.");
}
if (instanceIdentifier == null) {
throw new NullPointerException(
"The Instance Identifier must not be null.");
}
try {
PreparedStatement insertStmt = createInsertStatementFor(
connection, keeperKey,
ourIdentifier, instanceIdentifier);
try {
insertStmt.execute();
// No exception? We're the winner.
logger.info(ourIdentifier + " won grab for " +
instanceIdentifier);
return new AWinningOutcome(ourIdentifier,
keeperKey, instanceIdentifier);
}
catch (SQLException e) {
logger.info(ourIdentifier + " lost grab for " +
instanceIdentifier);
logger.debug("Lost with exception: " + e.toString());
}
finally {
insertStmt.close();
}
Query query = new Query(keeperKey, instanceIdentifier);
query.query();
if (ourIdentifier.equals(query.getWinner())
&& !query.isComplete()) {
// Winner must be restarting
return new AWinningOutcome(ourIdentifier,
keeperKey, instanceIdentifier);
}
// we could be the last winner and we completed which means
// we're restarting without persistence. So return a complete looser
// to avoid re-running.
ALoosingOutcome looser = new ALoosingOutcome(
query.getWinner(), keeperKey, instanceIdentifier);
return looser;
}
catch (SQLException e) {
throw new RuntimeException(e);
}
}
@Override
public String toString() {
return "The Keeper: " + keeperKey;
}
};
}
/**
* Winning Outcome.
*/
class AWinningOutcome implements WinningOutcome {
private final String winner;
private final String keeperKey;
private final Object instanceIdentifier;
public AWinningOutcome(String winner,
String keeperKey, Object instanceIdentifier) {
this.winner = winner;
this.keeperKey = keeperKey;
this.instanceIdentifier = instanceIdentifier;
}
@Override
public boolean isWon() {
return true;
}
@Override
public String getWinner() {
return winner;
}
@Override
public void complete() {
try {
PreparedStatement updateStmt = createUpdateStatementFor(
connection, keeperKey, instanceIdentifier);
int count = updateStmt.executeUpdate();
logger.info("Set complete keeper complete, update count [" +
count + "]");
updateStmt.close();
}
catch (SQLException e) {
throw new RuntimeException(e);
}
}
}
/**
* Encapsulates querying the database for the winner is complete.
* @author rob
*
*/
class Query {
private boolean complete;
private String winner;
private final String keeperKey;
private final Object instanceIdentifier;
public Query(String keeperKey, Object instanceIdentifier) {
this.keeperKey = keeperKey;
this.instanceIdentifier = instanceIdentifier;
}
void query() throws SQLException {
PreparedStatement queryStmt = createQueryStatementFor(
connection, keeperKey, instanceIdentifier);
ResultSet rs = queryStmt.executeQuery();
if (!rs.next()) {
throw new IllegalStateException(
"No row for " + keeperKey + ", " +
instanceIdentifier);
}
winner = rs.getString(1);
complete = rs.getBoolean(2);
queryStmt.close();
}
public String getWinner() {
return winner;
}
public boolean isComplete() {
return complete;
}
}
/**
* Encapsulate Polling.
*/
class Poll implements Runnable {
private final ALoosingOutcome loosing;
private final String keeperKey;
private final Object instanceIdentifier;
private volatile Future<?> future;
private volatile ScheduleContext scheduleContext =
new ScheduleContext(new Date());
public Poll(ALoosingOutcome loosing,
String keeperKey,
Object instanceIdentifier) {
this.loosing = loosing;
this.keeperKey = keeperKey;
this.instanceIdentifier = instanceIdentifier;
}
@Override
public void run() {
synchronized (loosers) {
loosers.remove(loosing);
}
try {
Query query = new Query(keeperKey, instanceIdentifier);
query.query();
if (query.isComplete()) {
loosing.stateHandler.waitToWhen(new IsAnyState(),
new Runnable() {
public void run() {
loosing.stateHandler.setState(JobState.COMPLETE);
loosing.stateHandler.fireEvent();
}
});
}
else {
ScheduleResult nextDue = pollSchedule.nextDue(scheduleContext);
if (nextDue == null) {
loosing.stateHandler.waitToWhen(new IsAnyState(),
new Runnable() {
public void run() {
loosing.stateHandler.setStateException(
JobState.EXCEPTION,
new Exception("Job failed to complete " +
"in expected time."));
loosing.stateHandler.fireEvent();
}
});
}
else {
scheduleContext = scheduleContext.move(
new IntervalTo(nextDue).getToDate());
long delay = nextDue.getToDate().getTime() -
new Date().getTime();
if (delay <= 0) {
delay = 0;
}
synchronized (loosers) {
if (running) {
this.future =
scheduler.schedule(this, delay,
TimeUnit.MILLISECONDS);
loosers.add(loosing);
}
}
}
}
}
catch (SQLException e) {
logger.error("Failed to Poll Keeper.", e);
}
}
private void stop() {
if (future != null) {
future.cancel(false);
future = null;
}
}
}
/**
* A Loosing Outcome.
*
*/
class ALoosingOutcome implements LoosingOutcome {
private final String winner;
private final JobStateHandler stateHandler =
new JobStateHandler(this);
private final Poll poll;
public ALoosingOutcome(String winner, String keeperKey,
Object instanceIdentifier) {
this.winner = winner;
poll = new Poll(this, keeperKey, instanceIdentifier);
}
@Override
public void addStateListener(StateListener listener) {
if (stateHandler.listenerCount() == 0) {
stateHandler.waitToWhen(new IsAnyState(),
new Runnable() {
@Override
public void run() {
stateHandler.setState(JobState.EXECUTING);
}
});
poll.run();
}
stateHandler.addStateListener(listener);
}
@Override
public void removeStateListener(StateListener listener) {
stateHandler.removeStateListener(listener);
if (stateHandler.listenerCount() == 0) {
synchronized (loosers) {
loosers.remove(this);
}
poll.stop();
}
}
@Override
public StateEvent lastStateEvent() {
return stateHandler.lastStateEvent();
}
private void stop() {
poll.stop();
stateHandler.waitToWhen(new IsStoppable(),
new Runnable() {
public void run() {
stateHandler.setState(JobState.INCOMPLETE);
stateHandler.fireEvent();
}
});
}
@Override
public String getWinner() {
return winner;
}
@Override
public boolean isWon() {
return false;
}
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
/**
* Set the connection.
*
* @param connection The connection.
*/
public void setConnection(Connection connection) throws SQLException {
this.connection = connection;
}
/**
* @oddjob.property pollerCount
* @oddjob.description The number of outstanding loosing outcome's
* polling of the database that are still in progress.
*
* @return The number.
*/
public int getPollerCount() {
return loosers.size();
}
/**
* Provide a PreparedStatement for the insert operation.
*
* @param connection
* @param keeperKey
* @param ourIdentifier
* @param instanceIdentifier
*
* @return
*
* @throws SQLException
*/
protected PreparedStatement createInsertStatementFor(
Connection connection, String keeperKey,
String ourIdentifier, Object instanceIdentifier)
throws SQLException {
PreparedStatement insertStmt = connection.prepareStatement(
"insert into " + getTable() + " (key, instance, winner) " +
" values (?, ?, ?)");
insertStmt.setString(1, keeperKey);
insertStmt.setObject(2, instanceIdentifier);
insertStmt.setString(3, ourIdentifier);
return insertStmt;
}
/**
* Create the PreparedStatement for the query of who won
* and is work complete yet.
*
* @param connection
* @param keeperKey
* @param instanceIdentifier
* @return
* @throws SQLException
*/
protected PreparedStatement createQueryStatementFor(Connection connection,
String keeperKey, Object instanceIdentifier)
throws SQLException {
PreparedStatement queryStmt = connection.prepareStatement(
"select winner, complete from " + getTable() +
" where key = ? and instance = ?");
queryStmt.setString(1, keeperKey);
queryStmt.setObject(2, instanceIdentifier);
return queryStmt;
}
/**
* Create the PreparedStatemenet for updating won work is
* complete.
*
* @param connection
* @param keeperKey
* @param instanceIdentifier
* @return
* @throws SQLException
*/
protected PreparedStatement createUpdateStatementFor(Connection connection,
String keeperKey, Object instanceIdentifier)
throws SQLException {
PreparedStatement updateStmt = connection.prepareStatement(
"update " + getTable() +
" set complete = true where key = ? and instance = ?");
updateStmt.setString(1, keeperKey);
updateStmt.setObject(2, instanceIdentifier);
return updateStmt;
}
public String getTable() {
return table;
}
public void setTable(String table) {
this.table = table;
}
public Schedule getPollSchedule() {
return pollSchedule;
}
public void setPollSchedule(Schedule schedule) {
this.pollSchedule = schedule;
}
@Inject
public void setScheduleExecutorService(ScheduledExecutorService scheduler) {
this.scheduler = scheduler;
}
@Override
public String toString() {
if (name == null) {
return getClass().getSimpleName();
}
return name;
}
}