Skip to content

Commit d4a7dfd

Browse files
committed
Add shutdown hook to gracefully stop job executions on sigterm
Resolves #5028
1 parent e5fbc2a commit d4a7dfd

File tree

9 files changed

+461
-0
lines changed

9 files changed

+461
-0
lines changed
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright 2025-present 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 org.apache.commons.logging.Log;
19+
import org.apache.commons.logging.LogFactory;
20+
21+
import org.springframework.batch.core.job.JobExecution;
22+
import org.springframework.batch.core.launch.JobOperator;
23+
24+
/**
25+
* A shutdown hook that attempts to gracefully stop a running job execution when the JVM
26+
* is exiting.
27+
*
28+
* @author Mahmoud Ben Hassine
29+
* @since 6.0
30+
*/
31+
public class JobExecutionShutdownHook extends Thread {
32+
33+
protected Log logger = LogFactory.getLog(JobExecutionShutdownHook.class);
34+
35+
private final JobExecution jobExecution;
36+
37+
private final JobOperator jobOperator;
38+
39+
/**
40+
* Create a new {@link JobExecutionShutdownHook}.
41+
* @param jobExecution the job execution to stop
42+
* @param jobOperator the job operator to use to stop the job execution
43+
*/
44+
public JobExecutionShutdownHook(JobExecution jobExecution, JobOperator jobOperator) {
45+
this.jobExecution = jobExecution;
46+
this.jobOperator = jobOperator;
47+
}
48+
49+
public void run() {
50+
this.logger.info("Received JVM shutdown signal");
51+
long jobExecutionId = this.jobExecution.getId();
52+
try {
53+
this.logger.info("Attempting to gracefully stop job execution " + jobExecutionId);
54+
this.jobOperator.stop(this.jobExecution);
55+
this.logger.info("Successfully stopped job execution " + jobExecutionId);
56+
}
57+
catch (Exception e) {
58+
throw new RuntimeException("Unable to gracefully stop job execution " + jobExecutionId);
59+
}
60+
}
61+
62+
}

spring-batch-samples/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,11 @@
217217
<artifactId>jackson-databind</artifactId>
218218
<version>${jackson.version}</version>
219219
</dependency>
220+
<dependency>
221+
<groupId>tools.jackson.core</groupId>
222+
<artifactId>jackson-databind</artifactId>
223+
<version>${jackson3.version}</version>
224+
</dependency>
220225
<dependency>
221226
<groupId>org.slf4j</groupId>
222227
<artifactId>slf4j-simple</artifactId>
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/*
2+
* Copyright 2025-present 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.samples.shutdown;
17+
18+
import javax.sql.DataSource;
19+
20+
import org.postgresql.ds.PGSimpleDataSource;
21+
22+
import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing;
23+
import org.springframework.batch.core.configuration.annotation.EnableJdbcJobRepository;
24+
import org.springframework.batch.core.configuration.annotation.StepScope;
25+
import org.springframework.batch.core.job.Job;
26+
import org.springframework.batch.core.job.builder.JobBuilder;
27+
import org.springframework.batch.core.repository.ExecutionContextSerializer;
28+
import org.springframework.batch.core.repository.JobRepository;
29+
import org.springframework.batch.core.repository.dao.Jackson3ExecutionContextStringSerializer;
30+
import org.springframework.batch.core.step.Step;
31+
import org.springframework.batch.core.step.builder.StepBuilder;
32+
import org.springframework.batch.infrastructure.item.database.JdbcBatchItemWriter;
33+
import org.springframework.batch.infrastructure.item.database.JdbcCursorItemReader;
34+
import org.springframework.batch.infrastructure.item.database.builder.JdbcBatchItemWriterBuilder;
35+
import org.springframework.batch.infrastructure.item.database.builder.JdbcCursorItemReaderBuilder;
36+
import org.springframework.beans.factory.annotation.Value;
37+
import org.springframework.context.annotation.Bean;
38+
import org.springframework.context.annotation.Configuration;
39+
import org.springframework.context.annotation.PropertySource;
40+
import org.springframework.core.env.Environment;
41+
import org.springframework.core.task.TaskExecutor;
42+
import org.springframework.jdbc.core.DataClassRowMapper;
43+
import org.springframework.jdbc.support.JdbcTransactionManager;
44+
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
45+
46+
/**
47+
* Sample batch configuration for demonstrating job shutdown and restart.
48+
*
49+
* @author Mahmoud Ben Hassine
50+
*/
51+
@Configuration
52+
@PropertySource("classpath:org/springframework/batch/samples/shutdown/application.properties")
53+
@EnableBatchProcessing(taskExecutorRef = "batchTaskExecutor")
54+
@EnableJdbcJobRepository
55+
class JobConfiguration {
56+
57+
record Vet(int id, String firstname, String lastname) {
58+
}
59+
60+
@Bean
61+
@StepScope
62+
public JdbcCursorItemReader<Vet> vetsReader(DataSource dataSource, @Value("#{jobParameters['minId']}") long minId,
63+
@Value("#{jobParameters['maxId']}") long maxId) {
64+
String sql = "select * from vets_in where id >= " + minId + " and id <= " + maxId + " order by id";
65+
return new JdbcCursorItemReaderBuilder<Vet>().name("vetsReader")
66+
.dataSource(dataSource)
67+
.sql(sql)
68+
.rowMapper(new DataClassRowMapper<>(Vet.class))
69+
.build();
70+
}
71+
72+
@Bean
73+
public JdbcBatchItemWriter<Vet> vetsWriter(DataSource dataSource) {
74+
String sql = "insert into vets_out (id, firstname, lastname) values (:id, :firstname, :lastname)";
75+
return new JdbcBatchItemWriterBuilder<Vet>().dataSource(dataSource).sql(sql).beanMapped().build();
76+
}
77+
78+
@Bean
79+
public Step step(JobRepository jobRepository, JdbcTransactionManager transactionManager,
80+
JdbcCursorItemReader<Vet> vetsReader, JdbcBatchItemWriter<Vet> vetsWriter) {
81+
return new StepBuilder("step", jobRepository).<Vet, Vet>chunk(2)
82+
.transactionManager(transactionManager)
83+
.reader(vetsReader)
84+
.processor(vet -> {
85+
Thread.sleep(5000); // simulate slow processing
86+
System.out.println("Processing vet " + vet);
87+
return new Vet(vet.id, vet.firstname.toUpperCase(), vet.lastname.toUpperCase());
88+
})
89+
.writer(vetsWriter)
90+
.build();
91+
}
92+
93+
@Bean
94+
public Job Job(JobRepository jobRepository, Step step) {
95+
return new JobBuilder("job", jobRepository).start(step).build();
96+
}
97+
98+
// infrastructure beans
99+
@Bean
100+
public DataSource dataSource(Environment environment) {
101+
PGSimpleDataSource dataSource = new PGSimpleDataSource();
102+
dataSource.setUrl(environment.getProperty("spring.datasource.url"));
103+
dataSource.setUser(environment.getProperty("spring.datasource.username"));
104+
dataSource.setPassword(environment.getProperty("spring.datasource.password"));
105+
return dataSource;
106+
}
107+
108+
@Bean
109+
public JdbcTransactionManager transactionManager(DataSource dataSource) {
110+
return new JdbcTransactionManager(dataSource);
111+
}
112+
113+
@Bean
114+
public ExecutionContextSerializer executionContextSerializer() {
115+
return new Jackson3ExecutionContextStringSerializer();
116+
}
117+
118+
// run jobs in background threads to allow the main thread to continue
119+
// and register shutdown hooks
120+
@Bean
121+
public TaskExecutor batchTaskExecutor() {
122+
ThreadPoolTaskExecutor batchTaskExecutor = new ThreadPoolTaskExecutor();
123+
batchTaskExecutor.setCorePoolSize(1);
124+
batchTaskExecutor.setMaxPoolSize(10); // max of 10 parallel jobs at a time
125+
batchTaskExecutor.setThreadNamePrefix("batch-");
126+
return batchTaskExecutor;
127+
}
128+
129+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Graceful Shutdown sample
2+
3+
## About the sample
4+
5+
This sample demonstrates how to implement a graceful shutdown in a Spring Batch application.
6+
It includes a job that simulates a long-running task and a shutdown hook that ensures the job
7+
is stopped gracefully when the application receives a termination signal.
8+
9+
The job consists of a single step that processes a large number of items, simulating a long-running task.
10+
The shutdown hook listens for termination signals (e.g., SIGTERM) and attempts to stop the job execution
11+
gracefully, allowing it to complete its current processing before shutting down.
12+
13+
## Run the sample
14+
15+
First, you need to start the database server of the job repository. In a terminal, run:
16+
17+
```
18+
$>cd spring-batch-samples/src/main/resources/org/springframework/batch/samples/shutdown
19+
$>docker-compose up -d
20+
```
21+
22+
Then, run the `org.springframework.batch.samples.shutdown.StartJobExecutionApp` class in your IDE with no arguments.
23+
Get the process id from the first line of logs (needed for the stop):
24+
25+
```
26+
Process id = 73280
27+
```
28+
29+
In a second terminal, Gracefully stop the app by running:
30+
31+
```
32+
$>kill -15 73280
33+
```
34+
35+
You should see the shutdown hook being called:
36+
37+
```
38+
Received JVM shutdown signal
39+
Attempting to gracefully stop job execution 1
40+
[...]
41+
Successfully stopped job execution 1
42+
```
43+
44+
Now, check the job execution status in the database with the following commands:
45+
46+
```
47+
$>docker exec postgres psql -U postgres -c 'select * from batch_job_execution;'
48+
$>docker exec postgres psql -U postgres -c 'select * from batch_step_execution;'
49+
```
50+
51+
Both the job and the step should have a `STOPPED` status.
52+
53+
Now, you can restart the job by running the `org.springframework.batch.samples.shutdown.RestartJobExecutionApp` class in your IDE.
54+
55+
You should see that the job is restarted from the last commit point and completes successfully.
56+
57+
## Clean up
58+
59+
To stop the database server, run:
60+
61+
```
62+
$>docker-compose down
63+
```
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright 2025-present 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.samples.shutdown;
17+
18+
import org.springframework.batch.core.job.JobExecution;
19+
import org.springframework.batch.core.launch.JobOperator;
20+
import org.springframework.batch.core.repository.JobRepository;
21+
import org.springframework.context.ApplicationContext;
22+
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
23+
24+
/**
25+
* Application to restart a job execution.
26+
*
27+
* @author Mahmoud Ben Hassine
28+
*/
29+
public class RestartJobExecutionApp {
30+
31+
public static void main(String[] args) throws Exception {
32+
ApplicationContext context = new AnnotationConfigApplicationContext(JobConfiguration.class);
33+
JobOperator jobOperator = context.getBean(JobOperator.class);
34+
JobRepository jobRepository = context.getBean(JobRepository.class);
35+
JobExecution jobExecution = jobRepository.getJobExecution(1);
36+
jobOperator.restart(jobExecution);
37+
}
38+
39+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright 2025-present 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.samples.shutdown;
17+
18+
import org.springframework.batch.core.job.Job;
19+
import org.springframework.batch.core.job.JobExecution;
20+
import org.springframework.batch.core.job.parameters.JobParameters;
21+
import org.springframework.batch.core.job.parameters.JobParametersBuilder;
22+
import org.springframework.batch.core.launch.JobOperator;
23+
import org.springframework.batch.core.launch.support.JobExecutionShutdownHook;
24+
import org.springframework.context.ApplicationContext;
25+
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
26+
27+
/**
28+
* Application to start a job execution.
29+
*
30+
* @author Mahmoud Ben Hassine
31+
*/
32+
public class StartJobExecutionApp {
33+
34+
public static void main(String[] args) throws Exception {
35+
System.out.println("Process id = " + ProcessHandle.current().pid());
36+
ApplicationContext context = new AnnotationConfigApplicationContext(JobConfiguration.class);
37+
JobOperator jobOperator = context.getBean(JobOperator.class);
38+
Job job = context.getBean(Job.class);
39+
JobParameters jobParameters = new JobParametersBuilder().addLong("minId", 1L)
40+
.addLong("maxId", 10L)
41+
.toJobParameters();
42+
JobExecution jobExecution = jobOperator.start(job, jobParameters);
43+
Thread springBatchHook = new JobExecutionShutdownHook(jobExecution, jobOperator);
44+
Runtime.getRuntime().addShutdownHook(springBatchHook);
45+
}
46+
47+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
spring.datasource.url=jdbc:postgresql://localhost:5432/postgres
2+
spring.datasource.username=postgres
3+
spring.datasource.password=postgres
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
services:
2+
postgres:
3+
container_name: postgres
4+
image: postgres:16.8-alpine
5+
environment:
6+
POSTGRES_USER: postgres
7+
POSTGRES_PASSWORD: postgres
8+
POSTGRES_DB: postgres
9+
ports:
10+
- "5432:5432"
11+
volumes:
12+
- ./schema-postgresql.sql:/docker-entrypoint-initdb.d/init.sql

0 commit comments

Comments
 (0)