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;
+ }
+ }
+
+ }
+
+}