Skip to content

Commit bd72104

Browse files
committed
Merge pull request #43086 from nosan
* pr/43086: Polish 'Add the ability to trigger a Quartz job through an Actuator endpoint' Add the ability to trigger a Quartz job through an Actuator endpoint Closes gh-43086
2 parents ddc45ea + 9881f38 commit bd72104

File tree

8 files changed

+245
-6
lines changed

8 files changed

+245
-6
lines changed

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,42 @@ include::partial$rest/actuator/quartz/job-details/response-fields.adoc[]
157157

158158

159159

160+
[[quartz.trigger-job]]
161+
== Trigger Quartz Job On Demand
162+
163+
To trigger a particular Quartz job, make a `POST` request to `/actuator/quartz/jobs/\{groupName}/\{jobName}`, as shown in the following curl-based example:
164+
165+
include::partial$rest/actuator/quartz/trigger-job/curl-request.adoc[]
166+
167+
The preceding example demonstrates how to trigger a job that belongs to the `samples` group and is named `jobOne`.
168+
169+
The response will look similar to the following:
170+
171+
include::partial$rest/actuator/quartz/trigger-job/http-response.adoc[]
172+
173+
174+
175+
[[quartz.trigger-job.request-structure]]
176+
=== Request Structure
177+
178+
The request specifies a desired `state` associated with a particular job.
179+
Sending an HTTP Request with a `"state": "running"` body indicates that the job should be run now.
180+
The following table describes the structure of the request:
181+
182+
[cols="2,1,3"]
183+
include::partial$rest/actuator/quartz/trigger-job/request-fields.adoc[]
184+
185+
[[quartz.trigger-job.response-structure]]
186+
=== Response Structure
187+
188+
The response contains the details of a triggered job.
189+
The following table describes the structure of the response:
190+
191+
[cols="2,1,3"]
192+
include::partial$rest/actuator/quartz/trigger-job/response-fields.adoc[]
193+
194+
195+
160196
[[quartz.trigger]]
161197
== Retrieving Details of a Trigger
162198

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

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2024 the original author or authors.
2+
* Copyright 2012-2025 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.
@@ -24,6 +24,7 @@
2424
import java.util.Date;
2525
import java.util.LinkedHashSet;
2626
import java.util.List;
27+
import java.util.Map;
2728
import java.util.Map.Entry;
2829
import java.util.TimeZone;
2930

@@ -54,9 +55,11 @@
5455
import org.springframework.boot.actuate.endpoint.Show;
5556
import org.springframework.boot.actuate.quartz.QuartzEndpoint;
5657
import org.springframework.boot.actuate.quartz.QuartzEndpointWebExtension;
58+
import org.springframework.boot.json.JsonWriter;
5759
import org.springframework.context.annotation.Bean;
5860
import org.springframework.context.annotation.Configuration;
5961
import org.springframework.context.annotation.Import;
62+
import org.springframework.http.MediaType;
6063
import org.springframework.restdocs.payload.FieldDescriptor;
6164
import org.springframework.restdocs.payload.JsonFieldType;
6265
import org.springframework.scheduling.quartz.DelegatingJob;
@@ -68,8 +71,12 @@
6871
import static org.mockito.BDDMockito.given;
6972
import static org.mockito.Mockito.mock;
7073
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
74+
import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest;
75+
import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse;
76+
import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint;
7177
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
7278
import static org.springframework.restdocs.payload.PayloadDocumentation.relaxedResponseFields;
79+
import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields;
7380
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
7481
import static org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath;
7582

@@ -385,6 +392,23 @@ void quartzTriggerCustom() throws Exception {
385392
.andWithPrefix("custom.", customTriggerSummary)));
386393
}
387394

395+
@Test
396+
void quartzTriggerJob() throws Exception {
397+
mockJobs(jobOne);
398+
String json = JsonWriter.standard().writeToString(Map.of("state", "running"));
399+
assertThat(this.mvc.post()
400+
.content(json)
401+
.contentType(MediaType.APPLICATION_JSON)
402+
.uri("/actuator/quartz/jobs/samples/jobOne"))
403+
.hasStatusOk()
404+
.apply(document("quartz/trigger-job", preprocessRequest(), preprocessResponse(prettyPrint()),
405+
requestFields(fieldWithPath("state").description("The desired state of the job.")),
406+
responseFields(fieldWithPath("group").description("Name of the group."),
407+
fieldWithPath("name").description("Name of the job."),
408+
fieldWithPath("className").description("Fully qualified name of the job implementation."),
409+
fieldWithPath("triggerTime").description("Time the job is triggered."))));
410+
}
411+
388412
private <T extends Trigger> void setupTriggerDetails(TriggerBuilder<T> builder, TriggerState state)
389413
throws SchedulerException {
390414
T trigger = builder.withIdentity("example", "samples")

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

Lines changed: 62 additions & 0 deletions
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,29 @@ 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+
return triggerQuartzJob(JobKey.jobKey(jobName, groupName));
227+
}
228+
229+
private QuartzJobTriggerDescriptor triggerQuartzJob(JobKey jobKey) throws SchedulerException {
230+
JobDetail jobDetail = this.scheduler.getJobDetail(jobKey);
231+
if (jobDetail == null) {
232+
return null;
233+
}
234+
this.scheduler.triggerJob(jobKey);
235+
return new QuartzJobTriggerDescriptor(jobDetail.getKey().getGroup(), jobDetail.getKey().getName(),
236+
jobDetail.getJobClass().getName(), Instant.now());
237+
}
238+
215239
private static List<Map<String, Object>> extractTriggersSummary(List<? extends Trigger> triggers) {
216240
List<Trigger> triggersToSort = new ArrayList<>(triggers);
217241
triggersToSort.sort(TRIGGER_COMPARATOR);
@@ -387,6 +411,44 @@ public String getClassName() {
387411

388412
}
389413

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

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

Lines changed: 14 additions & 1 deletion
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-2025 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, String state) throws SchedulerException {
86+
if (!"jobs".equals(jobs)) {
87+
return new WebEndpointResponse<>(WebEndpointResponse.STATUS_BAD_REQUEST);
88+
}
89+
if (!"running".equals(state)) {
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

Lines changed: 30 additions & 1 deletion
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-2025 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

Lines changed: 45 additions & 1 deletion
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-2025 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.
@@ -20,6 +20,7 @@
2020
import java.util.Collections;
2121
import java.util.LinkedHashSet;
2222
import java.util.List;
23+
import java.util.Map;
2324
import java.util.Map.Entry;
2425

2526
import net.minidev.json.JSONArray;
@@ -46,6 +47,7 @@
4647
import org.springframework.boot.actuate.endpoint.web.test.WebEndpointTest;
4748
import org.springframework.context.annotation.Bean;
4849
import org.springframework.context.annotation.Configuration;
50+
import org.springframework.http.MediaType;
4951
import org.springframework.scheduling.quartz.DelegatingJob;
5052
import org.springframework.test.web.reactive.server.WebTestClient;
5153
import org.springframework.util.LinkedMultiValueMap;
@@ -249,6 +251,48 @@ void quartzTriggerDetailWithUnknownKey(WebTestClient client) {
249251
client.get().uri("/actuator/quartz/triggers/tests/does-not-exist").exchange().expectStatus().isNotFound();
250252
}
251253

254+
@WebEndpointTest
255+
void quartzTriggerJob(WebTestClient client) {
256+
client.post()
257+
.uri("/actuator/quartz/jobs/samples/jobOne")
258+
.contentType(MediaType.APPLICATION_JSON)
259+
.bodyValue(Map.of("state", "running"))
260+
.exchange()
261+
.expectStatus()
262+
.isOk()
263+
.expectBody()
264+
.jsonPath("group")
265+
.isEqualTo("samples")
266+
.jsonPath("name")
267+
.isEqualTo("jobOne")
268+
.jsonPath("className")
269+
.isEqualTo("org.quartz.Job")
270+
.jsonPath("triggerTime")
271+
.isNotEmpty();
272+
}
273+
274+
@WebEndpointTest
275+
void quartzTriggerJobWithUnknownJobKey(WebTestClient client) {
276+
client.post()
277+
.uri("/actuator/quartz/jobs/samples/does-not-exist")
278+
.contentType(MediaType.APPLICATION_JSON)
279+
.bodyValue(Map.of("state", "running"))
280+
.exchange()
281+
.expectStatus()
282+
.isNotFound();
283+
}
284+
285+
@WebEndpointTest
286+
void quartzTriggerJobWithUnknownState(WebTestClient client) {
287+
client.post()
288+
.uri("/actuator/quartz/jobs/samples/jobOne")
289+
.contentType(MediaType.APPLICATION_JSON)
290+
.bodyValue(Map.of("state", "unknown"))
291+
.exchange()
292+
.expectStatus()
293+
.isBadRequest();
294+
}
295+
252296
@Configuration(proxyBeanMethods = false)
253297
static class TestConfiguration {
254298

spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/src/main/java/smoketest/quartz/SampleQuartzApplication.java

Lines changed: 10 additions & 1 deletion
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-2025 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.
@@ -58,6 +58,15 @@ public JobDetail anotherJobDetail() {
5858
.build();
5959
}
6060

61+
@Bean
62+
public JobDetail onDemandJobDetail() {
63+
return JobBuilder.newJob(SampleJob.class)
64+
.withIdentity("onDemandJob", "samples")
65+
.usingJobData("name", "On Demand Job")
66+
.storeDurably()
67+
.build();
68+
}
69+
6170
@Bean
6271
public Trigger everyTwoSecTrigger() {
6372
return TriggerBuilder.newTrigger()

0 commit comments

Comments
 (0)