/* ==================================================================
* DerbyOnlineSyncService.java - Mar 14, 2015 5:16:07 PM
*
* Copyright 2007-2015 SolarNetwork.net Dev Team
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License as
* published by the Free Software Foundation; either version 2 of
* the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA
* 02111-1307 USA
* ==================================================================
*/
package net.solarnetwork.node.dao.jdbc.derby;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.sql.CallableStatement;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.osgi.service.event.Event;
import org.osgi.service.event.EventHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.CallableStatementCallback;
import org.springframework.jdbc.core.CallableStatementCreator;
import org.springframework.jdbc.core.ConnectionCallback;
import org.springframework.jdbc.core.JdbcOperations;
import net.solarnetwork.node.Constants;
import net.solarnetwork.node.dao.SettingDao;
import net.solarnetwork.node.setup.SetupService;
/**
* Job to backup the Derby database using the
* {@code SYSCS_UTIL.SYSCS_FREEZE_DATABASE} procedure and {@code rsync}.
*
* <p>
* This job is designed with using an OS tool like rsync to make a copy of the
* Derby database to a backup location.
* </p>
*
* <p>
* The configurable properties of this class are:
* </p>
*
* <dl class="class-properties">
* <dt>jdbcOperations</dt>
* <dd>The {@link JdbcOperations} to use for executing SQL statements.</dd>
*
* <dt>syncCommand</dt>
* <dd>The OS command to execute to perform the backup sync. This defaults to
* {@code rsync -am --delete --exclude *.lck --stats __SOURCE_DIR__ __DEST_DIR__}
* . The {@code __SOURCE_DIR__} and {@code __DEST_DIR__} values are placeholders
* that will be replaced by the directory path for the Derby database and the
* value of the {@code destinationPath} property.</dd>
*
* <dt>destinationPath</dt>
* <dd>The path to perform the backup sync to. Defaults to
* {@code /var/tmp}.</dd>
* </dl>
*
* @author matt
* @version 1.1
*/
public class DerbyOnlineSyncService implements EventHandler {
/**
* The placeholder string in the {@code syncCommand} for the source
* directory path.
*/
public static final String SOURCE_DIRECTORY_PLACEHOLDER = "__SOURCE_DIR__";
/**
* The placeholder string in the {@code syncCommand} for the destination
* directory path.
*/
public static final String DESTINATION_DIRECTORY_PLACEHOLDER = "__DEST_DIR__";
/** The default value for the {@code destinationPath} property. */
public static final String DEFAULT_DESTINATION_PATH = "/var/tmp";
/** The default value of the {@code syncCommand} property. */
public static final List<String> DEFAULT_SYNC_COMMAND = Collections
.unmodifiableList(Arrays.asList("rsync", "-am", "--delete", "--exclude", "*.lck", "--stats",
SOURCE_DIRECTORY_PLACEHOLDER, DESTINATION_DIRECTORY_PLACEHOLDER));
private static final String FREEZE_CALL = "{CALL SYSCS_UTIL.SYSCS_FREEZE_DATABASE()}";
private static final String UNFREEZE_CALL = "{CALL SYSCS_UTIL.SYSCS_UNFREEZE_DATABASE()}";
private JdbcOperations jdbcOperations;
private List<String> syncCommand = DEFAULT_SYNC_COMMAND;
private String destinationPath = DEFAULT_DESTINATION_PATH;
private long syncSoonSeconds = 10;
private ScheduledExecutorService scheduler;
private ScheduledFuture<Boolean> syncSoonFuture;
private final Logger log = LoggerFactory.getLogger(getClass());
/**
* Call to destroy this service, cleaning up any resources.
*/
public void destroy() {
ScheduledExecutorService s = scheduler;
if ( s != null ) {
s.shutdown();
}
}
/**
* Listen for events to automatically trigger a database sync.
*
* The {@link SetupService#TOPIC_NETWORK_ASSOCIATION_ACCEPTED} and
* {@link SettingDao#EVENT_TOPIC_SETTING_CHANGED} and
* {@link Constants#EVENT_TOPIC_CONFIGURATION_CHANGED} event topics are
* handled, and will cause a sync to be scheduled in the "near future".
*/
@Override
public void handleEvent(Event event) {
if ( SetupService.TOPIC_NETWORK_ASSOCIATION_ACCEPTED.equals(event.getTopic()) ) {
// immediately sync database!
log.info("Scheduling database backup sync after network association acceptance event.");
syncSoon();
} else if ( SettingDao.EVENT_TOPIC_SETTING_CHANGED.equals(event.getTopic()) ) {
log.info("Scheduling database backup sync after setting change event.");
syncSoon();
} else if ( Constants.EVENT_TOPIC_CONFIGURATION_CHANGED.equals(event.getTopic()) ) {
log.info("Scheduling database backup sync after configuration change event.");
syncSoon();
}
}
private synchronized void syncSoon() {
if ( scheduler == null ) {
scheduler = Executors.newSingleThreadScheduledExecutor();
}
if ( syncSoonFuture != null ) {
if ( !syncSoonFuture.isDone() && !syncSoonFuture.cancel(false) ) {
return;
}
}
syncSoonFuture = scheduler.schedule(new Callable<Boolean>() {
@Override
public Boolean call() throws Exception {
log.info("Performing database backup sync.");
sync();
return true;
}
}, syncSoonSeconds, TimeUnit.SECONDS);
}
/**
* Perform an online database sync. Calling this method will call the
* {@code SYSCS_UTIL.SYSCS_FREEZE_DATABASE} procedure, use {@code rsync} to
* sync the database directory to another directory, and finally call the
* {@code SYSCS_UTIL.SYSCS_UNFREEZE_DATABASE} procedure.
*/
public synchronized void sync() {
log.debug("Starting Derby backup sync job");
String dbPath = getDbPath();
if ( dbPath == null ) {
return;
}
// freeze database for backup...
executeProcedure(FREEZE_CALL);
try {
// perform OK backup (rsync) now...
performSync(dbPath);
} finally {
// unfreeze database
executeProcedure(UNFREEZE_CALL);
}
}
private String getDbPath() {
String dbPath = jdbcOperations.execute(new ConnectionCallback<String>() {
@Override
public String doInConnection(Connection con) throws SQLException, DataAccessException {
DatabaseMetaData meta = con.getMetaData();
String url = meta.getURL();
Pattern pat = Pattern.compile("^jdbc:derby:(\\w+)", Pattern.CASE_INSENSITIVE);
Matcher m = pat.matcher(url);
String dbName;
if ( m.find() ) {
dbName = m.group(1);
} else {
log.warn("Unable to find Derby database name in connection URL: {}", url);
return null;
}
String home = System.getProperty("derby.system.home", "");
File f = new File(home, dbName);
return f.getPath();
}
});
return dbPath;
}
private void performSync(String dbPath) {
assert syncCommand != null;
List<String> cmd = new ArrayList<String>(syncCommand.size());
for ( String param : syncCommand ) {
param = param.replace(SOURCE_DIRECTORY_PLACEHOLDER, dbPath);
param = param.replace(DESTINATION_DIRECTORY_PLACEHOLDER, destinationPath);
cmd.add(param);
}
if ( log.isDebugEnabled() ) {
StringBuilder buf = new StringBuilder();
for ( String p : cmd ) {
if ( buf.length() > 0 ) {
buf.append(' ');
}
buf.append(p);
}
log.debug("Derby sync command: {}", buf.toString());
}
ProcessBuilder pb = new ProcessBuilder(cmd);
BufferedReader in = null;
PrintWriter out = null;
try {
Process pr = pb.start();
pr.waitFor();
if ( pr.exitValue() == 0 ) {
if ( log.isDebugEnabled() ) {
in = new BufferedReader(new InputStreamReader(pr.getInputStream()));
StringBuilder buf = new StringBuilder();
String line = null;
while ( (line = in.readLine()) != null ) {
buf.append(line).append('\n');
}
log.debug("Derby sync command output:\n{}", buf.toString());
}
log.info("Derby backup sync complete");
} else {
StringBuilder buf = new StringBuilder();
in = new BufferedReader(new InputStreamReader(pr.getErrorStream()));
String line = null;
while ( (line = in.readLine()) != null ) {
buf.append(line).append('\n');
}
log.error("Sync command returned non-zero exit code {}: {}", pr.exitValue(),
buf.toString().trim());
}
} catch ( IOException e ) {
throw new RuntimeException(e);
} catch ( InterruptedException e ) {
throw new RuntimeException(e);
} finally {
if ( in != null ) {
try {
in.close();
} catch ( IOException e ) {
// ignore
}
}
if ( out != null ) {
out.flush();
out.close();
}
}
}
private void executeProcedure(final String procedure) {
jdbcOperations.execute(new CallableStatementCreator() {
@Override
public CallableStatement createCallableStatement(Connection con) throws SQLException {
log.trace("Calling {} procedure", procedure);
return con.prepareCall(procedure);
}
}, new CallableStatementCallback<Object>() {
@Override
public Object doInCallableStatement(CallableStatement cs)
throws SQLException, DataAccessException {
cs.execute();
return null;
}
});
}
public JdbcOperations getJdbcOperations() {
return jdbcOperations;
}
public void setJdbcOperations(JdbcOperations jdbcOperations) {
this.jdbcOperations = jdbcOperations;
}
public List<String> getSyncCommand() {
return syncCommand;
}
public void setSyncCommand(List<String> syncCommand) {
this.syncCommand = syncCommand;
}
public String getDestinationPath() {
return destinationPath;
}
public void setDestinationPath(String destinationPath) {
this.destinationPath = destinationPath;
}
public long getSyncSoonSeconds() {
return syncSoonSeconds;
}
/**
* Set the number of seconds to delay the syncing of the database due to
* events in {@link #handleEvent(Event)}. This delay is implemented so that
* when many events are triggered closely in time (say, from multiple
* setting changes close together) we only try to sync once for the complete
* set of changes.
*
* @param syncSoonSeconds
* The number of seconds to treat as the "near future".
*/
public void setSyncSoonSeconds(long syncSoonSeconds) {
this.syncSoonSeconds = syncSoonSeconds;
}
}