Skip to content

Commit 709aa31

Browse files
committed
Add the ability to trigger a Quartz job on-demand through an Actuator endpoint
Before this commit, triggering a Quartz job on demand was not possible. This commit introduces a new @WriteOperation endpoint at /actuator/quartz/jobs/{groupName}/{jobName}/trigger, allowing a job to be triggered by specifying the jobName and groupName See gh-42530
1 parent 83492a6 commit 709aa31

File tree

6 files changed

+176
-3
lines changed

6 files changed

+176
-3
lines changed

spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/antora/modules/api/pages/rest/actuator/quartz.adoc

+22
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,28 @@ The following table describes the structure of the response:
156156
include::partial$rest/actuator/quartz/job-details/response-fields.adoc[]
157157

158158

159+
[[quartz.trigger-job]]
160+
== Trigger Quartz Job On Demand
161+
162+
To trigger a particular Quartz job, make a `POST` request to `/actuator/quartz/jobs/\{groupName}/\{jobName}/trigger`, as shown in the following curl-based example:
163+
164+
include::partial$rest/actuator/quartz/trigger-job/curl-request.adoc[]
165+
166+
The preceding example demonstrates how to trigger a job that belongs to the `samples` group and is named `jobOne`.
167+
168+
The response will look similar to the following:
169+
170+
include::partial$rest/actuator/quartz/trigger-job/http-response.adoc[]
171+
172+
[[quartz.trigger-job.response-structure]]
173+
=== Response Structure
174+
175+
The response contains the details of a triggered job.
176+
The following table describes the structure of the response:
177+
178+
[cols="2,1,3"]
179+
include::partial$rest/actuator/quartz/trigger-job/response-fields.adoc[]
180+
159181

160182
[[quartz.trigger]]
161183
== Retrieving Details of a Trigger

spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/QuartzEndpointDocumentationTests.java

+11
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,17 @@ void quartzTriggerCustom() throws Exception {
385385
.andWithPrefix("custom.", customTriggerSummary)));
386386
}
387387

388+
@Test
389+
void quartzTriggerJob() throws Exception {
390+
mockJobs(jobOne);
391+
assertThat(this.mvc.post().uri("/actuator/quartz/jobs/samples/jobOne/trigger")).hasStatusOk()
392+
.apply(document("quartz/trigger-job",
393+
responseFields(fieldWithPath("group").description("Name of the group."),
394+
fieldWithPath("name").description("Name of the job."),
395+
fieldWithPath("className").description("Fully qualified name of the job implementation."),
396+
fieldWithPath("triggerTime").description("Time the job is triggered."))));
397+
}
398+
388399
private <T extends Trigger> void setupTriggerDetails(TriggerBuilder<T> builder, TriggerState state)
389400
throws SchedulerException {
390401
T trigger = builder.withIdentity("example", "samples")

spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/QuartzEndpoint.java

+59
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package org.springframework.boot.actuate.quartz;
1818

1919
import java.time.Duration;
20+
import java.time.Instant;
2021
import java.time.LocalTime;
2122
import java.time.temporal.ChronoUnit;
2223
import java.time.temporal.TemporalUnit;
@@ -212,6 +213,26 @@ public QuartzJobDetailsDescriptor quartzJob(String groupName, String jobName, bo
212213
return null;
213214
}
214215

216+
/**
217+
* Triggers (execute it now) a Quartz job by its group and job name.
218+
* @param groupName the name of the job's group
219+
* @param jobName the name of the job
220+
* @return a description of the triggered job or {@code null} if the job does not
221+
* exist
222+
* @throws SchedulerException if there is an error triggering the job
223+
* @since 3.5.0
224+
*/
225+
public QuartzJobTriggerDescriptor triggerQuartzJob(String groupName, String jobName) throws SchedulerException {
226+
JobKey jobKey = JobKey.jobKey(jobName, groupName);
227+
JobDetail jobDetail = this.scheduler.getJobDetail(jobKey);
228+
if (jobDetail == null) {
229+
return null;
230+
}
231+
this.scheduler.triggerJob(jobKey);
232+
return new QuartzJobTriggerDescriptor(jobDetail.getKey().getGroup(), jobDetail.getKey().getName(),
233+
jobDetail.getJobClass().getName(), Instant.now());
234+
}
235+
215236
private static List<Map<String, Object>> extractTriggersSummary(List<? extends Trigger> triggers) {
216237
List<Trigger> triggersToSort = new ArrayList<>(triggers);
217238
triggersToSort.sort(TRIGGER_COMPARATOR);
@@ -387,6 +408,44 @@ public String getClassName() {
387408

388409
}
389410

411+
/**
412+
* Description of a triggered on demand {@link Job Quartz Job}.
413+
*/
414+
public static final class QuartzJobTriggerDescriptor {
415+
416+
private final String group;
417+
418+
private final String name;
419+
420+
private final String className;
421+
422+
private final Instant triggerTime;
423+
424+
private QuartzJobTriggerDescriptor(String group, String name, String className, Instant triggerTime) {
425+
this.group = group;
426+
this.name = name;
427+
this.className = className;
428+
this.triggerTime = triggerTime;
429+
}
430+
431+
public String getGroup() {
432+
return this.group;
433+
}
434+
435+
public String getName() {
436+
return this.name;
437+
}
438+
439+
public String getClassName() {
440+
return this.className;
441+
}
442+
443+
public Instant getTriggerTime() {
444+
return this.triggerTime;
445+
}
446+
447+
}
448+
390449
/**
391450
* Description of a {@link Job Quartz Job}.
392451
*/

spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/QuartzEndpointWebExtension.java

+14-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2022 the original author or authors.
2+
* Copyright 2012-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -27,6 +27,7 @@
2727
import org.springframework.boot.actuate.endpoint.Show;
2828
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
2929
import org.springframework.boot.actuate.endpoint.annotation.Selector;
30+
import org.springframework.boot.actuate.endpoint.annotation.WriteOperation;
3031
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
3132
import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExtension;
3233
import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzGroupsDescriptor;
@@ -79,6 +80,18 @@ public WebEndpointResponse<Object> quartzJobOrTrigger(SecurityContext securityCo
7980
() -> this.delegate.quartzTrigger(group, name, showUnsanitized));
8081
}
8182

83+
@WriteOperation
84+
public WebEndpointResponse<Object> triggerQuartzJob(@Selector String jobs, @Selector String group,
85+
@Selector String name, @Selector String action) throws SchedulerException {
86+
if (!"jobs".equals(jobs)) {
87+
return new WebEndpointResponse<>(WebEndpointResponse.STATUS_BAD_REQUEST);
88+
}
89+
if (!"trigger".equals(action)) {
90+
return new WebEndpointResponse<>(WebEndpointResponse.STATUS_BAD_REQUEST);
91+
}
92+
return handleNull(this.delegate.triggerQuartzJob(group, name));
93+
}
94+
8295
private <T> WebEndpointResponse<T> handle(String jobsOrTriggers, ResponseSupplier<T> jobAction,
8396
ResponseSupplier<T> triggerAction) throws SchedulerException {
8497
if ("jobs".equals(jobsOrTriggers)) {

spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/quartz/QuartzEndpointTests.java

+30-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2023 the original author or authors.
2+
* Copyright 2012-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -66,16 +66,20 @@
6666
import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzJobDetailsDescriptor;
6767
import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzJobGroupSummaryDescriptor;
6868
import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzJobSummaryDescriptor;
69+
import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzJobTriggerDescriptor;
6970
import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzTriggerGroupSummaryDescriptor;
7071
import org.springframework.scheduling.quartz.DelegatingJob;
7172
import org.springframework.util.LinkedMultiValueMap;
7273
import org.springframework.util.MultiValueMap;
7374

7475
import static org.assertj.core.api.Assertions.assertThat;
7576
import static org.assertj.core.api.Assertions.entry;
77+
import static org.assertj.core.api.Assertions.within;
78+
import static org.mockito.ArgumentMatchers.any;
7679
import static org.mockito.BDDMockito.given;
7780
import static org.mockito.BDDMockito.then;
7881
import static org.mockito.Mockito.mock;
82+
import static org.mockito.Mockito.never;
7983

8084
/**
8185
* Tests for {@link QuartzEndpoint}.
@@ -755,6 +759,31 @@ void quartzJobWithDataMapAndShowUnsanitizedFalse() throws SchedulerException {
755759
entry("url", "******"));
756760
}
757761

762+
@Test
763+
void quartzJobShouldBeTriggered() throws SchedulerException {
764+
JobDetail job = JobBuilder.newJob(Job.class)
765+
.withIdentity("hello", "samples")
766+
.withDescription("A sample job")
767+
.storeDurably()
768+
.requestRecovery(false)
769+
.build();
770+
mockJobs(job);
771+
QuartzJobTriggerDescriptor quartzJobTriggerDescriptor = this.endpoint.triggerQuartzJob("samples", "hello");
772+
assertThat(quartzJobTriggerDescriptor).isNotNull();
773+
assertThat(quartzJobTriggerDescriptor.getName()).isEqualTo("hello");
774+
assertThat(quartzJobTriggerDescriptor.getGroup()).isEqualTo("samples");
775+
assertThat(quartzJobTriggerDescriptor.getClassName()).isEqualTo("org.quartz.Job");
776+
assertThat(quartzJobTriggerDescriptor.getTriggerTime()).isCloseTo(Instant.now(), within(5, ChronoUnit.SECONDS));
777+
then(this.scheduler).should().triggerJob(new JobKey("hello", "samples"));
778+
}
779+
780+
@Test
781+
void quartzJobShouldNotBeTriggeredJobDoesNotExist() throws SchedulerException {
782+
QuartzJobTriggerDescriptor quartzJobTriggerDescriptor = this.endpoint.triggerQuartzJob("samples", "hello");
783+
assertThat(quartzJobTriggerDescriptor).isNull();
784+
then(this.scheduler).should(never()).triggerJob(any());
785+
}
786+
758787
private void mockJobs(JobDetail... jobs) throws SchedulerException {
759788
MultiValueMap<String, JobKey> jobKeys = new LinkedMultiValueMap<>();
760789
for (JobDetail jobDetail : jobs) {

spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/quartz/QuartzEndpointWebIntegrationTests.java

+40-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2023 the original author or authors.
2+
* Copyright 2012-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -46,6 +46,7 @@
4646
import org.springframework.boot.actuate.endpoint.web.test.WebEndpointTest;
4747
import org.springframework.context.annotation.Bean;
4848
import org.springframework.context.annotation.Configuration;
49+
import org.springframework.http.MediaType;
4950
import org.springframework.scheduling.quartz.DelegatingJob;
5051
import org.springframework.test.web.reactive.server.WebTestClient;
5152
import org.springframework.util.LinkedMultiValueMap;
@@ -249,6 +250,44 @@ void quartzTriggerDetailWithUnknownKey(WebTestClient client) {
249250
client.get().uri("/actuator/quartz/triggers/tests/does-not-exist").exchange().expectStatus().isNotFound();
250251
}
251252

253+
@WebEndpointTest
254+
void quartzTriggerJob(WebTestClient client) {
255+
client.post()
256+
.uri("/actuator/quartz/jobs/samples/jobOne/trigger")
257+
.exchange()
258+
.expectStatus()
259+
.isOk()
260+
.expectBody()
261+
.jsonPath("group")
262+
.isEqualTo("samples")
263+
.jsonPath("name")
264+
.isEqualTo("jobOne")
265+
.jsonPath("className")
266+
.isEqualTo("org.quartz.Job")
267+
.jsonPath("triggerTime")
268+
.isNotEmpty();
269+
}
270+
271+
@WebEndpointTest
272+
void quartzTriggerJobWithUnknownKey(WebTestClient client) {
273+
client.post()
274+
.uri("/actuator/quartz/jobs/samples/does-not-exist/trigger")
275+
.contentType(MediaType.APPLICATION_JSON)
276+
.exchange()
277+
.expectStatus()
278+
.isNotFound();
279+
}
280+
281+
@WebEndpointTest
282+
void quartzTriggerJobWithInvalidAction(WebTestClient client) {
283+
client.post()
284+
.uri("/actuator/quartz/jobs/samples/jobOne/invalid-action")
285+
.contentType(MediaType.APPLICATION_JSON)
286+
.exchange()
287+
.expectStatus()
288+
.isBadRequest();
289+
}
290+
252291
@Configuration(proxyBeanMethods = false)
253292
static class TestConfiguration {
254293

0 commit comments

Comments
 (0)