diff --git a/spring-batch-samples/src/main/java/org/springframework/batch/sample/tasklet/ConfigurableSystemProcessExitCodeMapper.java b/spring-batch-samples/src/main/java/org/springframework/batch/sample/tasklet/ConfigurableSystemProcessExitCodeMapper.java new file mode 100644 index 000000000..360858048 --- /dev/null +++ b/spring-batch-samples/src/main/java/org/springframework/batch/sample/tasklet/ConfigurableSystemProcessExitCodeMapper.java @@ -0,0 +1,40 @@ +package org.springframework.batch.sample.tasklet; + +import java.util.Map; + +import org.springframework.batch.repeat.ExitStatus; +import org.springframework.util.Assert; + +/** + * Maps exit codes to {@link org.springframework.batch.repeat.ExitStatus} + * according to injected map. The injected map is required to contain a value + * for 'else' key, this value will be returned if the injected map + * does not contain value for the exit code returned by the system process. + * + * @author Robert Kasanicky + */ +public class ConfigurableSystemProcessExitCodeMapper implements SystemProcessExitCodeMapper { + + public static final String ELSE_KEY = "else"; + + private Map mappings; + + public ExitStatus getExitStatus(int exitCode) { + ExitStatus exitStatus = (ExitStatus) mappings.get(new Integer(exitCode)); + if (exitStatus != null) { + return exitStatus; + } else { + return (ExitStatus) mappings.get(ELSE_KEY); + } + } + + /** + * @param mappings Integer exit code keys to + * {@link org.springframework.batch.repeat.ExitStatus} values. + */ + public void setMappings(Map mappings) { + Assert.notNull(mappings.get(ELSE_KEY)); + this.mappings = mappings; + } + +} diff --git a/spring-batch-samples/src/main/java/org/springframework/batch/sample/tasklet/SimpleSystemProcessExitCodeMapper.java b/spring-batch-samples/src/main/java/org/springframework/batch/sample/tasklet/SimpleSystemProcessExitCodeMapper.java new file mode 100644 index 000000000..380ec2a05 --- /dev/null +++ b/spring-batch-samples/src/main/java/org/springframework/batch/sample/tasklet/SimpleSystemProcessExitCodeMapper.java @@ -0,0 +1,22 @@ +package org.springframework.batch.sample.tasklet; + +import org.springframework.batch.repeat.ExitStatus; + +/** + * Simple {@link SystemProcessExitCodeMapper} implementation that performs following mapping: + * + * 0 -> ExitStatus.FINISHED + * else -> ExitStatus.FAILED + * + * @author Robert Kasanicky + */ +public class SimpleSystemProcessExitCodeMapper implements SystemProcessExitCodeMapper { + public ExitStatus getExitStatus(int exitCode) { + if (exitCode == 0) { + return ExitStatus.FINISHED; + } else { + return ExitStatus.FAILED; + } + } + +} diff --git a/spring-batch-samples/src/main/java/org/springframework/batch/sample/tasklet/SystemCommandException.java b/spring-batch-samples/src/main/java/org/springframework/batch/sample/tasklet/SystemCommandException.java new file mode 100644 index 000000000..148de6e31 --- /dev/null +++ b/spring-batch-samples/src/main/java/org/springframework/batch/sample/tasklet/SystemCommandException.java @@ -0,0 +1,18 @@ +package org.springframework.batch.sample.tasklet; + +/** + * Exception indicating failed execution of system command. + */ +public class SystemCommandException extends RuntimeException { + + // generated + private static final long serialVersionUID = 5139355923336176733L; + + public SystemCommandException(String message) { + super(message); + } + + public SystemCommandException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/spring-batch-samples/src/main/java/org/springframework/batch/sample/tasklet/SystemCommandTasklet.java b/spring-batch-samples/src/main/java/org/springframework/batch/sample/tasklet/SystemCommandTasklet.java new file mode 100644 index 000000000..816e19ff6 --- /dev/null +++ b/spring-batch-samples/src/main/java/org/springframework/batch/sample/tasklet/SystemCommandTasklet.java @@ -0,0 +1,128 @@ +package org.springframework.batch.sample.tasklet; + +import java.io.File; +import java.io.IOException; + +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.ExitStatus; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.util.Assert; + +/** + * {@link Tasklet} that executes a system command. + * + * The system command is executed in a new thread - timeout value is required to + * be set, so that the batch job does not hang forever if the external process + * hangs. + * + * @author Robert Kasanicky + */ +public class SystemCommandTasklet implements Tasklet, InitializingBean { + + private String command; + + private String[] environmentParams = null; + + private File workingDirectory = null; + + private SystemProcessExitCodeMapper systemProcessExitCodeMapper = new SimpleSystemProcessExitCodeMapper(); + + private long timeout = 0; + + /** + * Execute system command and map its exit code to {@link ExitStatus} using + * {@link SystemProcessExitCodeMapper}. + */ + public ExitStatus execute() throws Exception { + ExecutorThread executorThread = new ExecutorThread(); + executorThread.start(); + executorThread.join(timeout); + + if (executorThread.finishedSuccessfully) { + return systemProcessExitCodeMapper.getExitStatus(executorThread.exitCode); + } + else { + executorThread.interrupt(); + throw new SystemCommandException( + "Execution of system command failed (did not finish successfully within the timeout)"); + } + + } + + /** + * @param command command to be executed in a separate system process + */ + public void setCommand(String command) { + this.command = command; + } + + /** + * @param envp environment parameter values, inherited from parent process + * when not set (or set to null). + */ + public void setEnvironmentParams(String[] envp) { + this.environmentParams = envp; + } + + /** + * @param dir working directory of the spawned process, inherited from + * parent process when not set (or set to null). + */ + public void setWorkingDirectory(String dir) { + if (dir == null) { + this.workingDirectory = null; + return; + } + this.workingDirectory = new File(dir); + Assert.isTrue(workingDirectory.exists(), "working directory must exist"); + Assert.isTrue(workingDirectory.isDirectory(), "working directory value must be a directory"); + + } + + public void afterPropertiesSet() throws Exception { + Assert.hasLength(command, "'command' property value is required"); + Assert.notNull(systemProcessExitCodeMapper, "SystemProcessExitCodeMapper must be set"); + Assert.isTrue(timeout > 0, "timeout value must be greater than zero"); + } + + /** + * @param systemProcessExitCodeMapper maps system process return value to + * ExitStatus returned by Tasklet. + * {@link SimpleSystemProcessExitCodeMapper} is used by default. + */ + public void setSystemProcessExitCodeMapper(SystemProcessExitCodeMapper systemProcessExitCodeMapper) { + this.systemProcessExitCodeMapper = systemProcessExitCodeMapper; + } + + /** + * @param timeout upper limit for how long the execution of the external + * program is allowed to last. + */ + public void setTimeout(long timeout) { + this.timeout = timeout; + } + + /** + * Thread that executes the system command. + */ + private class ExecutorThread extends Thread { + volatile int exitCode = -1; + + volatile boolean finishedSuccessfully = false; + + public void run() { + try { + Process process = Runtime.getRuntime().exec(command, environmentParams, workingDirectory); + exitCode = process.waitFor(); + finishedSuccessfully = true; + } + catch (IOException e) { + throw new SystemCommandException("IO error while executing system command", e); + } + catch (InterruptedException e) { + throw new SystemCommandException("Interrupted while executing system command", e); + } + } + } + +} diff --git a/spring-batch-samples/src/main/java/org/springframework/batch/sample/tasklet/SystemProcessExitCodeMapper.java b/spring-batch-samples/src/main/java/org/springframework/batch/sample/tasklet/SystemProcessExitCodeMapper.java new file mode 100644 index 000000000..02101a5ad --- /dev/null +++ b/spring-batch-samples/src/main/java/org/springframework/batch/sample/tasklet/SystemProcessExitCodeMapper.java @@ -0,0 +1,19 @@ +package org.springframework.batch.sample.tasklet; + +import org.springframework.batch.repeat.ExitStatus; + +/** + * Maps the exit code of a system process to ExitStatus value + * returned by a system command. Designed for use with the + * {@link SystemCommandTasklet}. + * + * @author Robert Kasanicky + */ +public interface SystemProcessExitCodeMapper { + + /** + * @param exitCode exit code returned by the system process + * @return ExitStatus appropriate for the systemExitCode parameter value + */ + ExitStatus getExitStatus(int exitCode); +} diff --git a/spring-batch-samples/src/main/resources/jobs/taskletJob.xml b/spring-batch-samples/src/main/resources/jobs/taskletJob.xml index 38c5c2414..e81b3b978 100644 --- a/spring-batch-samples/src/main/resources/jobs/taskletJob.xml +++ b/spring-batch-samples/src/main/resources/jobs/taskletJob.xml @@ -14,20 +14,35 @@ is the kind of task that is not natural to split into read and write. In this case the step is wrapped in a standalone job, however typically it would be a setup step in a multi-step job. + + The second step illustrates executing a system command. - - - - - - - + + + + + + + + + + + + + + + + + + + ExitStatus.FINISHED + * else -> ExitStatus.FAILED + */ + public void testMapping() { + assertEquals(ExitStatus.FINISHED, mapper.getExitStatus(0)); + assertEquals(ExitStatus.FAILED, mapper.getExitStatus(1)); + assertEquals(ExitStatus.FAILED, mapper.getExitStatus(-1)); + } +} diff --git a/spring-batch-samples/src/test/java/org/springframework/batch/sample/tasklet/SystemCommandTaskletIntegrationTests.java b/spring-batch-samples/src/test/java/org/springframework/batch/sample/tasklet/SystemCommandTaskletIntegrationTests.java new file mode 100644 index 000000000..865268297 --- /dev/null +++ b/spring-batch-samples/src/test/java/org/springframework/batch/sample/tasklet/SystemCommandTaskletIntegrationTests.java @@ -0,0 +1,168 @@ +package org.springframework.batch.sample.tasklet; + +import java.io.File; + +import junit.framework.TestCase; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.batch.repeat.ExitStatus; +import org.springframework.batch.sample.tasklet.SystemCommandException; +import org.springframework.batch.sample.tasklet.SystemCommandTasklet; +import org.springframework.batch.sample.tasklet.SystemProcessExitCodeMapper; +import org.springframework.util.Assert; + +/** + * Tests for {@link SystemCommandTasklet}. + */ +public class SystemCommandTaskletIntegrationTests extends TestCase { + + private static final Log log = LogFactory.getLog(SystemCommandTaskletIntegrationTests.class); + + private SystemCommandTasklet tasklet = new SystemCommandTasklet(); + + protected void setUp() throws Exception { + tasklet.setEnvironmentParams(null); // inherit from parent process + tasklet.setWorkingDirectory(null); // inherit from parent process + tasklet.setSystemProcessExitCodeMapper(new TestExitCodeMapper()); + tasklet.setTimeout(5000); // long enough timeout + } + + /** + * Regular usage scenario - successful execution of system command. + */ + public void testExecute() throws Exception { + String command = "java -version"; + tasklet.setCommand(command); + tasklet.afterPropertiesSet(); + + log.info("Executing command: " + command); + ExitStatus exitStatus = tasklet.execute(); + + assertEquals(ExitStatus.FINISHED, exitStatus); + } + + /** + * Failed execution scenario - error exit code returned by system command. + */ + public void testExecuteFailure() throws Exception { + String command = "java -invalid-argument"; + tasklet.setCommand(command); + tasklet.afterPropertiesSet(); + + log.info("Executing command: " + command); + ExitStatus exitStatus = tasklet.execute(); + + assertEquals(ExitStatus.FAILED, exitStatus); + } + + /** + * Failed execution scenario - execution time exceeds timeout. + */ + public void testExecuteTimeout() throws Exception { + String command = "sleep 3"; + tasklet.setCommand(command); + tasklet.setTimeout(10); + tasklet.afterPropertiesSet(); + + log.info("Executing command: " + command); + try { + tasklet.execute(); + fail(); + } + catch (SystemCommandException e) { + assertTrue(e.getMessage().contains("did not finish successfully within the timeout")); + } + } + + /** + * Command property value is required to be set. + */ + public void testCommandNotSet() throws Exception { + tasklet.setCommand(null); + try { + tasklet.afterPropertiesSet(); + fail(); + } + catch (IllegalArgumentException e) { + // expected + } + + tasklet.setCommand(""); + try { + tasklet.afterPropertiesSet(); + fail(); + } + catch (IllegalArgumentException e) { + // expected + } + } + + /** + * Timeout must be set to non-zero value. + */ + public void testTimeoutNotSet() throws Exception { + tasklet.setCommand("not-empty placeholder"); + tasklet.setTimeout(0); + try { + tasklet.afterPropertiesSet(); + fail(); + } + catch (IllegalArgumentException e) { + // expected + } + } + + /** + * Working directory property must point to an existing location and it must + * be a directory + */ + public void testWorkingDirectory() throws Exception { + File notExistingFile = new File("not-existing-path"); + Assert.state(!notExistingFile.exists()); + + try { + tasklet.setWorkingDirectory(notExistingFile.getCanonicalPath()); + fail(); + } + catch (IllegalArgumentException e) { + // expected + } + + File notDirectory = File.createTempFile(this.getClass().getSimpleName(), null); + Assert.state(notDirectory.exists()); + Assert.state(!notDirectory.isDirectory()); + + try { + tasklet.setWorkingDirectory(notDirectory.getCanonicalPath()); + fail(); + } + catch (IllegalArgumentException e) { + // expected + } + + File directory = notDirectory.getParentFile(); + Assert.state(directory.exists()); + Assert.state(directory.isDirectory()); + + // no error expected now + tasklet.setWorkingDirectory(directory.getCanonicalPath()); + } + + /** + * Exit code mapper containing mapping logic expected by the tests. 0 means + * finished successfully, other value means failure. + */ + private static class TestExitCodeMapper implements SystemProcessExitCodeMapper { + + public ExitStatus getExitStatus(int exitCode) { + if (exitCode == 0) { + return ExitStatus.FINISHED; + } else { + return ExitStatus.FAILED; + } + } + + } + +}