Skip to content

Commit e6a8088

Browse files
committed
Introduce a new CLI utility to operate jobs
Resolves #4899
1 parent 88d76d8 commit e6a8088

File tree

3 files changed

+445
-1
lines changed

3 files changed

+445
-1
lines changed
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
/*
2+
* Copyright 2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.batch.core.launch.support;
17+
18+
import java.util.Arrays;
19+
import java.util.List;
20+
import java.util.Properties;
21+
22+
import org.springframework.batch.core.configuration.JobRegistry;
23+
import org.springframework.batch.core.converter.DefaultJobParametersConverter;
24+
import org.springframework.batch.core.converter.JobParametersConverter;
25+
import org.springframework.batch.core.job.Job;
26+
import org.springframework.batch.core.job.JobExecution;
27+
import org.springframework.batch.core.job.parameters.JobParameters;
28+
import org.springframework.batch.core.launch.JobOperator;
29+
import org.springframework.batch.core.repository.JobRepository;
30+
import org.springframework.beans.BeansException;
31+
import org.springframework.context.ConfigurableApplicationContext;
32+
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
33+
import org.springframework.core.log.LogAccessor;
34+
35+
import static org.springframework.batch.core.launch.support.ExitCodeMapper.JVM_EXITCODE_COMPLETED;
36+
import static org.springframework.batch.core.launch.support.ExitCodeMapper.JVM_EXITCODE_GENERIC_ERROR;
37+
38+
/**
39+
* A command-line utility to operate Spring Batch jobs using the {@link JobOperator}. It
40+
* allows starting, stopping, restarting, and abandoning jobs from the command line.
41+
* <p>
42+
* This utility requires a Spring application context to be set up with the necessary
43+
* batch infrastructure, including a {@link JobOperator}, a {@link JobRepository}, and a
44+
* {@link JobRegistry} populated with the jobs to operate. It can also be configured with
45+
* a custom {@link ExitCodeMapper} and a {@link JobParametersConverter}.
46+
*
47+
* <p>
48+
* This class is designed to be run from the command line, and the Javadoc of the
49+
* {@link #main(String[])} method explains the various operations and exit codes.
50+
*
51+
* @author Mahmoud Ben Hassine
52+
* @since 6.0
53+
*/
54+
public class CommandLineJobOperator {
55+
56+
private static final LogAccessor logger = new LogAccessor(CommandLineJobOperator.class);
57+
58+
private final JobOperator jobOperator;
59+
60+
private final JobRepository jobRepository;
61+
62+
private final JobRegistry jobRegistry;
63+
64+
private ExitCodeMapper exitCodeMapper = new SimpleJvmExitCodeMapper();
65+
66+
private JobParametersConverter jobParametersConverter = new DefaultJobParametersConverter();
67+
68+
/**
69+
* Create a new {@link CommandLineJobOperator} instance.
70+
* @param jobOperator the {@link JobOperator} to use for job operations
71+
* @param jobRepository the {@link JobRepository} to use for job meta-data management
72+
* @param jobRegistry the {@link JobRegistry} to use for job lookup by name
73+
*/
74+
public CommandLineJobOperator(JobOperator jobOperator, JobRepository jobRepository, JobRegistry jobRegistry) {
75+
this.jobOperator = jobOperator;
76+
this.jobRepository = jobRepository;
77+
this.jobRegistry = jobRegistry;
78+
}
79+
80+
/**
81+
* Set the {@link JobParametersConverter} to use for converting command line
82+
* parameters to {@link JobParameters}. Defaults to a
83+
* {@link DefaultJobParametersConverter}.
84+
* @param jobParametersConverter the job parameters converter to set
85+
*/
86+
public void setJobParametersConverter(JobParametersConverter jobParametersConverter) {
87+
this.jobParametersConverter = jobParametersConverter;
88+
}
89+
90+
/**
91+
* Set the {@link ExitCodeMapper} to use for converting job exit codes to JVM exit
92+
* codes. Defaults to a {@link SimpleJvmExitCodeMapper}.
93+
* @param exitCodeMapper the exit code mapper to set
94+
*/
95+
public void setExitCodeMapper(ExitCodeMapper exitCodeMapper) {
96+
this.exitCodeMapper = exitCodeMapper;
97+
}
98+
99+
/**
100+
* Start a job with the given name and parameters.
101+
* @param jobName the name of the job to start
102+
* @param parameters the parameters for the job
103+
* @return the exit code of the job execution, or JVM_EXITCODE_GENERIC_ERROR if an
104+
* error occurs
105+
*/
106+
public int start(String jobName, Properties parameters) {
107+
logger.info(() -> "Starting job with name '" + jobName + "' and parameters: " + parameters);
108+
try {
109+
Job job = this.jobRegistry.getJob(jobName);
110+
JobParameters jobParameters = this.jobParametersConverter.getJobParameters(parameters);
111+
JobExecution jobExecution = this.jobOperator.start(job, jobParameters);
112+
return this.exitCodeMapper.intValue(jobExecution.getExitStatus().getExitCode());
113+
}
114+
catch (Exception e) {
115+
return JVM_EXITCODE_GENERIC_ERROR;
116+
}
117+
}
118+
119+
/**
120+
* Start the next instance of the job with the given name.
121+
* @param jobName the name of the job to start
122+
* @return the exit code of the job execution, or JVM_EXITCODE_GENERIC_ERROR if an
123+
* error occurs
124+
*/
125+
public int startNextInstance(String jobName) {
126+
logger.info(() -> "Starting next instance of job '" + jobName + "'");
127+
try {
128+
Job job = this.jobRegistry.getJob(jobName);
129+
JobExecution jobExecution = this.jobOperator.startNextInstance(job);
130+
return this.exitCodeMapper.intValue(jobExecution.getExitStatus().getExitCode());
131+
}
132+
catch (Exception e) {
133+
return JVM_EXITCODE_GENERIC_ERROR;
134+
}
135+
}
136+
137+
/**
138+
* Send a stop signal to the job execution with given ID. The signal is successfully
139+
* sent if this method returns JVM_EXITCODE_COMPLETED, but that doesn't mean that the
140+
* job has stopped. The only way to be sure of that is to poll the job execution
141+
* status.
142+
* @param jobExecutionId the ID of the job execution to stop
143+
* @return JVM_EXITCODE_COMPLETED if the stop signal was successfully sent to the job
144+
* execution, JVM_EXITCODE_GENERIC_ERROR otherwise
145+
* @see JobOperator#stop(JobExecution)
146+
*/
147+
public int stop(long jobExecutionId) {
148+
logger.info(() -> "Stopping job execution with ID: " + jobExecutionId);
149+
try {
150+
JobExecution jobExecution = this.jobRepository.getJobExecution(jobExecutionId);
151+
if (jobExecution == null) {
152+
logger.error(() -> "No job execution found with ID: " + jobExecutionId);
153+
return JVM_EXITCODE_GENERIC_ERROR;
154+
}
155+
boolean stopSignalSent = this.jobOperator.stop(jobExecution);
156+
return stopSignalSent ? JVM_EXITCODE_COMPLETED : JVM_EXITCODE_GENERIC_ERROR;
157+
}
158+
catch (Exception e) {
159+
return JVM_EXITCODE_GENERIC_ERROR;
160+
}
161+
}
162+
163+
/**
164+
* Restart the job execution with the given ID.
165+
* @param jobExecutionId the ID of the job execution to restart
166+
* @return the exit code of the restarted job execution, or JVM_EXITCODE_GENERIC_ERROR
167+
* if an error occurs
168+
*/
169+
public int restart(long jobExecutionId) {
170+
logger.info(() -> "Restarting job execution with ID: " + jobExecutionId);
171+
try {
172+
JobExecution jobExecution = this.jobRepository.getJobExecution(jobExecutionId);
173+
if (jobExecution == null) {
174+
logger.error(() -> "No job execution found with ID: " + jobExecutionId);
175+
return JVM_EXITCODE_GENERIC_ERROR;
176+
}
177+
JobExecution restartedExecution = this.jobOperator.restart(jobExecution);
178+
return this.exitCodeMapper.intValue(restartedExecution.getExitStatus().getExitCode());
179+
}
180+
catch (Exception e) {
181+
return JVM_EXITCODE_GENERIC_ERROR;
182+
}
183+
}
184+
185+
/**
186+
* Abandon the job execution with the given ID.
187+
* @param jobExecutionId the ID of the job execution to abandon
188+
* @return the exit code of the abandoned job execution, or JVM_EXITCODE_GENERIC_ERROR
189+
* if an error occurs
190+
*/
191+
public int abandon(long jobExecutionId) {
192+
logger.info(() -> "Abandoning job execution with ID: " + jobExecutionId);
193+
try {
194+
JobExecution jobExecution = this.jobRepository.getJobExecution(jobExecutionId);
195+
if (jobExecution == null) {
196+
logger.error(() -> "No job execution found with ID: " + jobExecutionId);
197+
return JVM_EXITCODE_GENERIC_ERROR;
198+
}
199+
JobExecution abandonedExecution = this.jobOperator.abandon(jobExecution);
200+
return this.exitCodeMapper.intValue(abandonedExecution.getExitStatus().getExitCode());
201+
}
202+
catch (Exception e) {
203+
return JVM_EXITCODE_GENERIC_ERROR;
204+
}
205+
}
206+
207+
/*
208+
* Main method to operate jobs from the command line.
209+
*
210+
* Usage: java org.springframework.batch.core.launch.support.CommandLineJobOperator \
211+
* fully.qualified.name.of.JobConfigurationClass \ operation \ parameters \
212+
*
213+
* where operation is one of the following: - start jobName [jobParameters] -
214+
* startNextInstance jobName - restart jobExecutionId - stop jobExecutionId - abandon
215+
* jobExecutionId
216+
*
217+
* and jobParameters are key-value pairs in the form name=value,type,identifying.
218+
*
219+
* Exit status: - 0: Job completed successfully - 1: Job failed to (re)start or an
220+
* error occurred - 2: Job configuration class not found
221+
*/
222+
public static void main(String[] args) {
223+
if (args.length < 3) {
224+
String usage = """
225+
Usage: java %s <fully.qualified.name.of.JobConfigurationClass> <operation> <parameters>
226+
where operation is one of the following:
227+
- start jobName [jobParameters]
228+
- startNextInstance jobName
229+
- restart jobExecutionId
230+
- stop jobExecutionId
231+
- abandon jobExecutionId
232+
and jobParameters are key-value pairs in the form name=value,type,identifying.
233+
""";
234+
System.err.printf(String.format(usage, CommandLineJobOperator.class.getName()));
235+
System.exit(1);
236+
}
237+
238+
String jobConfigurationClassName = args[0];
239+
String operation = args[1];
240+
241+
ConfigurableApplicationContext context = null;
242+
try {
243+
Class<?> jobConfigurationClass = Class.forName(jobConfigurationClassName);
244+
context = new AnnotationConfigApplicationContext(jobConfigurationClass);
245+
}
246+
catch (ClassNotFoundException classNotFoundException) {
247+
System.err.println("Job configuration class not found: " + jobConfigurationClassName);
248+
System.exit(2);
249+
}
250+
251+
JobOperator jobOperator = null;
252+
JobRepository jobRepository = null;
253+
JobRegistry jobRegistry = null;
254+
try {
255+
jobOperator = context.getBean(JobOperator.class);
256+
jobRepository = context.getBean(JobRepository.class);
257+
jobRegistry = context.getBean(JobRegistry.class);
258+
}
259+
catch (BeansException e) {
260+
System.err.println("A required bean was not found in the application context: " + e.getMessage());
261+
System.exit(1);
262+
}
263+
CommandLineJobOperator operator = new CommandLineJobOperator(jobOperator, jobRepository, jobRegistry);
264+
265+
int exitCode;
266+
String jobName;
267+
long jobExecutionId;
268+
switch (operation) {
269+
case "start":
270+
jobName = args[2];
271+
List<String> jobParameters = Arrays.asList(args).subList(3, args.length);
272+
exitCode = operator.start(jobName, parse(jobParameters));
273+
break;
274+
case "startNextInstance":
275+
jobName = args[2];
276+
exitCode = operator.startNextInstance(jobName);
277+
break;
278+
case "stop":
279+
jobExecutionId = Long.parseLong(args[2]);
280+
exitCode = operator.stop(jobExecutionId);
281+
break;
282+
case "restart":
283+
jobExecutionId = Long.parseLong(args[2]);
284+
exitCode = operator.restart(jobExecutionId);
285+
break;
286+
case "abandon":
287+
jobExecutionId = Long.parseLong(args[2]);
288+
exitCode = operator.abandon(jobExecutionId);
289+
break;
290+
default:
291+
System.err.println("Unknown operation: " + operation);
292+
exitCode = JVM_EXITCODE_GENERIC_ERROR;
293+
}
294+
295+
System.exit(exitCode);
296+
}
297+
298+
private static Properties parse(List<String> jobParameters) {
299+
Properties properties = new Properties();
300+
for (String jobParameter : jobParameters) {
301+
String[] tokens = jobParameter.split("=");
302+
properties.put(tokens[0], tokens[1]);
303+
}
304+
return properties;
305+
}
306+
307+
}

spring-batch-core/src/main/java/org/springframework/batch/core/launch/support/CommandLineJobRunner.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,8 @@
169169
* @author Mahmoud Ben Hassine
170170
* @author Minsoo Kim
171171
* @since 1.0
172-
* @deprecated since 6.0 with no replacement. Scheduled for removal in 6.2 or later.
172+
* @deprecated since 6.0 in favor of {@link CommandLineJobOperator}. Scheduled for removal
173+
* in 6.2 or later.
173174
*/
174175
@Deprecated(since = "6.0", forRemoval = true)
175176
public class CommandLineJobRunner {

0 commit comments

Comments
 (0)