diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/JobExecutionListener.java b/spring-batch-core/src/main/java/org/springframework/batch/core/JobExecutionListener.java
index bd0c7b6a92..5ded8efb70 100644
--- a/spring-batch-core/src/main/java/org/springframework/batch/core/JobExecutionListener.java
+++ b/spring-batch-core/src/main/java/org/springframework/batch/core/JobExecutionListener.java
@@ -42,4 +42,14 @@ default void beforeJob(JobExecution jobExecution) {
default void afterJob(JobExecution jobExecution) {
}
+ /**
+ * Callback after completion of a job and job metadata is saved.
+ * Called after both successful and failed executions.
+ * In contrast to {@link JobExecutionListener#afterJob(JobExecution)},
+ * it is not possible to change the given jobExecution as it was already saved.
+ * @param jobExecution the current {@link JobExecution}
+ */
+ default void afterJobSaved(JobExecution jobExecution) {
+ }
+
}
diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/annotation/AfterJobSaved.java b/spring-batch-core/src/main/java/org/springframework/batch/core/annotation/AfterJobSaved.java
new file mode 100644
index 0000000000..712fb008a0
--- /dev/null
+++ b/spring-batch-core/src/main/java/org/springframework/batch/core/annotation/AfterJobSaved.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2006-2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.batch.core.annotation;
+
+import org.springframework.batch.core.Job;
+import org.springframework.batch.core.JobExecution;
+import org.springframework.batch.core.JobExecutionListener;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a method to be called after a {@link Job} has completed and the job metadata is saved.
+ * Annotated methods are called regardless of the status of the {@link JobExecution}.
+ *
+ * Expected signature: void afterJobSaved({@link JobExecution} jobExecution)
+ *
+ * @see JobExecutionListener
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ ElementType.METHOD })
+public @interface AfterJobSaved {
+
+}
diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/job/AbstractJob.java b/spring-batch-core/src/main/java/org/springframework/batch/core/job/AbstractJob.java
index 34d6d19f58..7fbf989815 100644
--- a/spring-batch-core/src/main/java/org/springframework/batch/core/job/AbstractJob.java
+++ b/spring-batch-core/src/main/java/org/springframework/batch/core/job/AbstractJob.java
@@ -364,6 +364,13 @@ public final void execute(JobExecution execution) {
}
jobRepository.update(execution);
+
+ try {
+ listener.afterJobSaved(execution);
+ }
+ catch (Exception e) {
+ logger.error("Exception encountered in afterJobSaved callback", e);
+ }
}
finally {
JobSynchronizationManager.release();
diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/job/builder/JobBuilderHelper.java b/spring-batch-core/src/main/java/org/springframework/batch/core/job/builder/JobBuilderHelper.java
index dee585863e..9e2d500e6f 100644
--- a/spring-batch-core/src/main/java/org/springframework/batch/core/job/builder/JobBuilderHelper.java
+++ b/spring-batch-core/src/main/java/org/springframework/batch/core/job/builder/JobBuilderHelper.java
@@ -31,6 +31,7 @@
import org.springframework.batch.core.JobParametersIncrementer;
import org.springframework.batch.core.JobParametersValidator;
import org.springframework.batch.core.annotation.AfterJob;
+import org.springframework.batch.core.annotation.AfterJobSaved;
import org.springframework.batch.core.annotation.BeforeJob;
import org.springframework.batch.core.job.AbstractJob;
import org.springframework.batch.core.listener.JobListenerFactoryBean;
@@ -145,6 +146,7 @@ public B listener(Object listener) {
Set jobExecutionListenerMethods = new HashSet<>();
jobExecutionListenerMethods.addAll(ReflectionUtils.findMethod(listener.getClass(), BeforeJob.class));
jobExecutionListenerMethods.addAll(ReflectionUtils.findMethod(listener.getClass(), AfterJob.class));
+ jobExecutionListenerMethods.addAll(ReflectionUtils.findMethod(listener.getClass(), AfterJobSaved.class));
if (jobExecutionListenerMethods.size() > 0) {
JobListenerFactoryBean factory = new JobListenerFactoryBean();
diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/listener/CompositeJobExecutionListener.java b/spring-batch-core/src/main/java/org/springframework/batch/core/listener/CompositeJobExecutionListener.java
index 304b1b2a92..cba705fa58 100644
--- a/spring-batch-core/src/main/java/org/springframework/batch/core/listener/CompositeJobExecutionListener.java
+++ b/spring-batch-core/src/main/java/org/springframework/batch/core/listener/CompositeJobExecutionListener.java
@@ -74,4 +74,17 @@ public void beforeJob(JobExecution jobExecution) {
}
}
+ /**
+ * Call the registered listeners in reverse order, respecting and prioritising those
+ * that implement {@link Ordered}.
+ * @see org.springframework.batch.core.JobExecutionListener#afterJobSaved(org.springframework.batch.core.JobExecution)
+ */
+ @Override
+ public void afterJobSaved(JobExecution jobExecution) {
+ for (Iterator iterator = listeners.reverse(); iterator.hasNext();) {
+ JobExecutionListener listener = iterator.next();
+ listener.afterJobSaved(jobExecution);
+ }
+ }
+
}
diff --git a/spring-batch-core/src/main/java/org/springframework/batch/core/listener/JobListenerMetaData.java b/spring-batch-core/src/main/java/org/springframework/batch/core/listener/JobListenerMetaData.java
index 3f5b515502..6b901c1c39 100644
--- a/spring-batch-core/src/main/java/org/springframework/batch/core/listener/JobListenerMetaData.java
+++ b/spring-batch-core/src/main/java/org/springframework/batch/core/listener/JobListenerMetaData.java
@@ -22,6 +22,7 @@
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobExecutionListener;
import org.springframework.batch.core.annotation.AfterJob;
+import org.springframework.batch.core.annotation.AfterJobSaved;
import org.springframework.batch.core.annotation.BeforeJob;
import org.springframework.lang.Nullable;
@@ -37,7 +38,8 @@
public enum JobListenerMetaData implements ListenerMetaData {
BEFORE_JOB("beforeJob", "before-job-method", BeforeJob.class),
- AFTER_JOB("afterJob", "after-job-method", AfterJob.class);
+ AFTER_JOB("afterJob", "after-job-method", AfterJob.class),
+ AFTER_JOB_SAVED("afterJobSaved", "after-job-saved-method", AfterJobSaved .class);
private final String methodName;
diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/xml/JobExecutionListenerParserTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/xml/JobExecutionListenerParserTests.java
index 8f9d3a195d..e108d72c43 100644
--- a/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/xml/JobExecutionListenerParserTests.java
+++ b/spring-batch-core/src/test/java/org/springframework/batch/core/configuration/xml/JobExecutionListenerParserTests.java
@@ -22,6 +22,7 @@
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.annotation.AfterJob;
+import org.springframework.batch.core.annotation.AfterJobSaved;
import org.springframework.batch.core.annotation.BeforeJob;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.beans.factory.annotation.Autowired;
@@ -38,6 +39,8 @@ public class JobExecutionListenerParserTests {
public static boolean afterCalled = false;
+ public static boolean afterSavedCalled = false;
+
@Autowired
Job job;
@@ -51,6 +54,7 @@ void testListeners() throws Exception {
job.execute(jobExecution);
assertTrue(beforeCalled);
assertTrue(afterCalled);
+ assertTrue(afterSavedCalled);
}
public static class TestComponent {
@@ -65,6 +69,11 @@ public void after() {
afterCalled = true;
}
+ @AfterJobSaved
+ public void afterJobSaved() {
+ afterSavedCalled = true;
+ }
+
}
}
diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/job/builder/JobBuilderTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/job/builder/JobBuilderTests.java
index 9678a8e4cc..81d5ba2a3a 100644
--- a/spring-batch-core/src/test/java/org/springframework/batch/core/job/builder/JobBuilderTests.java
+++ b/spring-batch-core/src/test/java/org/springframework/batch/core/job/builder/JobBuilderTests.java
@@ -25,6 +25,7 @@
import org.springframework.batch.core.JobExecutionListener;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.annotation.AfterJob;
+import org.springframework.batch.core.annotation.AfterJobSaved;
import org.springframework.batch.core.annotation.BeforeJob;
import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing;
import org.springframework.batch.core.launch.JobLauncher;
@@ -60,9 +61,10 @@ void testListeners() throws Exception {
assertEquals(ExitStatus.COMPLETED, jobExecution.getExitStatus());
assertEquals(1, AnnotationBasedJobExecutionListener.beforeJobCount);
assertEquals(1, AnnotationBasedJobExecutionListener.afterJobCount);
+ assertEquals(1, AnnotationBasedJobExecutionListener.afterJobSavedCount);
assertEquals(1, InterfaceBasedJobExecutionListener.beforeJobCount);
assertEquals(1, InterfaceBasedJobExecutionListener.afterJobCount);
-
+ assertEquals(1, InterfaceBasedJobExecutionListener.afterJobSavedCount);
}
@Configuration
@@ -100,6 +102,8 @@ static class InterfaceBasedJobExecutionListener implements JobExecutionListener
public static int afterJobCount = 0;
+ public static int afterJobSavedCount = 0;
+
@Override
public void beforeJob(JobExecution jobExecution) {
beforeJobCount++;
@@ -110,6 +114,10 @@ public void afterJob(JobExecution jobExecution) {
afterJobCount++;
}
+ @Override
+ public void afterJobSaved(JobExecution jobExecution) {
+ afterJobSavedCount++;
+ }
}
static class AnnotationBasedJobExecutionListener {
@@ -118,6 +126,8 @@ static class AnnotationBasedJobExecutionListener {
public static int afterJobCount = 0;
+ public static int afterJobSavedCount = 0;
+
@BeforeJob
public void beforeJob(JobExecution jobExecution) {
beforeJobCount++;
@@ -128,6 +138,11 @@ public void afterJob(JobExecution jobExecution) {
afterJobCount++;
}
+ @AfterJobSaved
+ public void afterJobSaved() {
+ afterJobSavedCount++;
+ }
+
}
}
\ No newline at end of file
diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/listener/CompositeJobExecutionListenerTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/listener/CompositeJobExecutionListenerTests.java
index 0ee3e43c1a..867754daec 100644
--- a/spring-batch-core/src/test/java/org/springframework/batch/core/listener/CompositeJobExecutionListenerTests.java
+++ b/spring-batch-core/src/test/java/org/springframework/batch/core/listener/CompositeJobExecutionListenerTests.java
@@ -78,4 +78,16 @@ public void beforeJob(JobExecution stepExecution) {
assertEquals(1, list.size());
}
+ @Test
+ void testAfterJobSaved() {
+ listener.register(new JobExecutionListener() {
+ @Override
+ public void afterJobSaved(JobExecution jobExecution) {
+ list.add("foo");
+ }
+ });
+ listener.afterJobSaved(new JobExecution(new JobInstance(11L, "testOpenJob"), null));
+ assertEquals(1, list.size());
+ }
+
}
diff --git a/spring-batch-core/src/test/java/org/springframework/batch/core/listener/JobListenerFactoryBeanTests.java b/spring-batch-core/src/test/java/org/springframework/batch/core/listener/JobListenerFactoryBeanTests.java
index 59bdb90e40..5230ad7e93 100644
--- a/spring-batch-core/src/test/java/org/springframework/batch/core/listener/JobListenerFactoryBeanTests.java
+++ b/spring-batch-core/src/test/java/org/springframework/batch/core/listener/JobListenerFactoryBeanTests.java
@@ -57,8 +57,10 @@ void testWithInterface() {
JobExecution jobExecution = new JobExecution(11L);
listener.beforeJob(jobExecution);
listener.afterJob(jobExecution);
+ listener.afterJobSaved(jobExecution);
assertTrue(delegate.beforeJobCalled);
assertTrue(delegate.afterJobCalled);
+ assertTrue(delegate.afterJobSavedCalled);
}
@Test
@@ -240,6 +242,8 @@ private static class JobListenerWithInterface implements JobExecutionListener {
boolean afterJobCalled = false;
+ boolean afterJobSavedCalled = false;
+
@Override
public void afterJob(JobExecution jobExecution) {
afterJobCalled = true;
@@ -250,6 +254,11 @@ public void beforeJob(JobExecution jobExecution) {
beforeJobCalled = true;
}
+ @Override
+ public void afterJobSaved(JobExecution jobExecution) {
+ afterJobSavedCalled = true;
+ }
+
}
private static class AnnotatedTestClass {