Skip to content

Commit a356862

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, groupName, and an optional JobDataMap See spring-projectsgh-42530
1 parent ede1110 commit a356862

File tree

10 files changed

+352
-17
lines changed

10 files changed

+352
-17
lines changed

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

+34
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,40 @@ 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}/trigger`, as shown in the following curl-based example:
164+
165+
include::partial$rest/actuator/quartz/trigger-job/curl-request.adoc[]
166+
167+
The above example shows the triggering of the job, identified by the `samples` group and `jobOne` name, and with the specified `jobData`. The Quartz `jobData` is optional key-value data, that can be passed to the job when it's triggered.
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+
[[quartz.trigger-job.request-structure]]
175+
=== Request Structure
176+
177+
The request specifies an optional `jobData` associated with a particular job. The following table describes the structure of the request:
178+
179+
[cols="2,1,3"]
180+
include::partial$rest/actuator/quartz/trigger-job/request-fields.adoc[]
181+
182+
183+
[[quartz.trigger-job.response-structure]]
184+
=== Response Structure
185+
186+
The response contains the details of a triggered job.
187+
The following table describes the structure of the response:
188+
189+
[cols="2,1,3"]
190+
include::partial$rest/actuator/quartz/trigger-job/response-fields.adoc[]
191+
192+
193+
160194
[[quartz.trigger]]
161195
== Retrieving Details of a Trigger
162196

spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointHandlerMapping.java

+3-3
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.
@@ -147,12 +147,12 @@ private static class SecureReactiveWebOperation implements ReactiveWebOperation
147147
}
148148

149149
@Override
150-
public Mono<ResponseEntity<Object>> handle(ServerWebExchange exchange, Map<String, String> body) {
150+
public Mono<ResponseEntity<Object>> handle(ServerWebExchange exchange, Map<String, Object> body) {
151151
return this.securityInterceptor.preHandle(exchange, this.endpointId.toLowerCaseString())
152152
.flatMap((securityResponse) -> flatMapResponse(exchange, body, securityResponse));
153153
}
154154

155-
private Mono<ResponseEntity<Object>> flatMapResponse(ServerWebExchange exchange, Map<String, String> body,
155+
private Mono<ResponseEntity<Object>> flatMapResponse(ServerWebExchange exchange, Map<String, Object> body,
156156
SecurityResponse securityResponse) {
157157
if (!securityResponse.getStatus().equals(HttpStatus.OK)) {
158158
return Mono.just(new ResponseEntity<>(securityResponse.getStatus()));

spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryWebEndpointServletHandlerMapping.java

+2-2
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.
@@ -155,7 +155,7 @@ private static class SecureServletWebOperation implements ServletWebOperation {
155155
}
156156

157157
@Override
158-
public Object handle(HttpServletRequest request, Map<String, String> body) {
158+
public Object handle(HttpServletRequest request, Map<String, Object> body) {
159159
SecurityResponse securityResponse = this.securityInterceptor.preHandle(request, this.endpointId);
160160
if (!securityResponse.getStatus().equals(HttpStatus.OK)) {
161161
return new ResponseEntity<Object>(securityResponse.getMessage(), securityResponse.getStatus());

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

+29
Original file line numberDiff line numberDiff line change
@@ -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,7 +71,11 @@
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;
78+
import static org.springframework.restdocs.payload.PayloadDocumentation.relaxedRequestFields;
7279
import static org.springframework.restdocs.payload.PayloadDocumentation.relaxedResponseFields;
7380
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
7481
import static org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath;
@@ -385,6 +392,28 @@ void quartzTriggerCustom() throws Exception {
385392
.andWithPrefix("custom.", customTriggerSummary)));
386393
}
387394

395+
@Test
396+
void quartzTriggerJob() throws Exception {
397+
mockJobs(jobOne);
398+
String json = JsonWriter.<Map<String, Object>>of((members) -> members.addMapEntries(Map::copyOf))
399+
.write(Map.of("jobData", Map.of("key", "value", "keyN", "valueN")))
400+
.toJsonString();
401+
assertThat(this.mvc.post()
402+
.uri("/actuator/quartz/jobs/samples/jobOne/trigger")
403+
.content(json)
404+
.contentType(MediaType.APPLICATION_JSON))
405+
.hasStatusOk()
406+
.apply(document("quartz/trigger-job", preprocessRequest(), preprocessResponse(prettyPrint()),
407+
relaxedRequestFields(fieldWithPath("jobData").description(
408+
"A Quartz key-value JobDataMap, that will be associated with the trigger, that fires the job immediately."),
409+
fieldWithPath("jobData.key")
410+
.description("An arbitrary name that will be used as a key in JobDataMap.")),
411+
responseFields(fieldWithPath("group").description("Name of the group."),
412+
fieldWithPath("name").description("Name of the job."),
413+
fieldWithPath("className").description("Fully qualified name of the job implementation."),
414+
fieldWithPath("triggerTime").description("Time the job is triggered."))));
415+
}
416+
388417
private <T extends Trigger> void setupTriggerDetails(TriggerBuilder<T> builder, TriggerState state)
389418
throws SchedulerException {
390419
T trigger = builder.withIdentity("example", "samples")

spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java

+4-4
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ protected interface LinksHandler {
298298
@FunctionalInterface
299299
protected interface ReactiveWebOperation {
300300

301-
Mono<ResponseEntity<Object>> handle(ServerWebExchange exchange, Map<String, String> body);
301+
Mono<ResponseEntity<Object>> handle(ServerWebExchange exchange, Map<String, Object> body);
302302

303303
}
304304

@@ -349,7 +349,7 @@ Mono<SecurityContext> emptySecurityContext() {
349349
}
350350

351351
@Override
352-
public Mono<ResponseEntity<Object>> handle(ServerWebExchange exchange, Map<String, String> body) {
352+
public Mono<ResponseEntity<Object>> handle(ServerWebExchange exchange, Map<String, Object> body) {
353353
Map<String, Object> arguments = getArguments(exchange, body);
354354
OperationArgumentResolver serverNamespaceArgumentResolver = OperationArgumentResolver
355355
.of(WebServerNamespace.class, () -> WebServerNamespace
@@ -363,7 +363,7 @@ public Mono<ResponseEntity<Object>> handle(ServerWebExchange exchange, Map<Strin
363363
exchange.getRequest().getMethod()));
364364
}
365365

366-
private Map<String, Object> getArguments(ServerWebExchange exchange, Map<String, String> body) {
366+
private Map<String, Object> getArguments(ServerWebExchange exchange, Map<String, Object> body) {
367367
Map<String, Object> arguments = new LinkedHashMap<>(getTemplateVariables(exchange));
368368
String matchAllRemainingPathSegmentsVariable = this.operation.getRequestPredicate()
369369
.getMatchAllRemainingPathSegmentsVariable();
@@ -448,7 +448,7 @@ private static final class WriteOperationHandler {
448448
@ResponseBody
449449
@Reflective
450450
Publisher<ResponseEntity<Object>> handle(ServerWebExchange exchange,
451-
@RequestBody(required = false) Map<String, String> body) {
451+
@RequestBody(required = false) Map<String, Object> body) {
452452
return this.operation.handle(exchange, body);
453453
}
454454

spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/AbstractWebMvcEndpointHandlerMapping.java

+4-4
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ protected interface LinksHandler {
278278
@FunctionalInterface
279279
protected interface ServletWebOperation {
280280

281-
Object handle(HttpServletRequest request, Map<String, String> body);
281+
Object handle(HttpServletRequest request, Map<String, Object> body);
282282

283283
}
284284

@@ -308,7 +308,7 @@ private static class ServletWebOperationAdapter implements ServletWebOperation {
308308
}
309309

310310
@Override
311-
public Object handle(HttpServletRequest request, @RequestBody(required = false) Map<String, String> body) {
311+
public Object handle(HttpServletRequest request, @RequestBody(required = false) Map<String, Object> body) {
312312
HttpHeaders headers = new ServletServerHttpRequest(request).getHeaders();
313313
Map<String, Object> arguments = getArguments(request, body);
314314
try {
@@ -336,7 +336,7 @@ public String toString() {
336336
return "Actuator web endpoint '" + this.operation.getId() + "'";
337337
}
338338

339-
private Map<String, Object> getArguments(HttpServletRequest request, Map<String, String> body) {
339+
private Map<String, Object> getArguments(HttpServletRequest request, Map<String, Object> body) {
340340
Map<String, Object> arguments = new LinkedHashMap<>(getTemplateVariables(request));
341341
String matchAllRemainingPathSegmentsVariable = this.operation.getRequestPredicate()
342342
.getMatchAllRemainingPathSegmentsVariable();
@@ -430,7 +430,7 @@ private static final class OperationHandler {
430430

431431
@ResponseBody
432432
@Reflective
433-
Object handle(HttpServletRequest request, @RequestBody(required = false) Map<String, String> body) {
433+
Object handle(HttpServletRequest request, @RequestBody(required = false) Map<String, Object> body) {
434434
return this.operation.handle(request, body);
435435
}
436436

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

+67
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,34 @@ public QuartzJobDetailsDescriptor quartzJob(String groupName, String jobName, bo
212213
return null;
213214
}
214215

216+
/**
217+
* Trigger (execute it now) the job identified with the given group name and job name.
218+
* @param groupName the name of the group
219+
* @param jobName the name of the job, or {@code null}
220+
* @param jobData the job jobData
221+
* @return the execution description of the job or {@code null} if such job does not
222+
* exist
223+
* @throws SchedulerException if either triggering job or retrieving the information
224+
* from the scheduler failed
225+
* @since 3.5.0
226+
*/
227+
public QuartzJobTriggerDescriptor triggerQuartzJob(String groupName, String jobName, Map<String, Object> jobData)
228+
throws SchedulerException {
229+
JobKey jobKey = JobKey.jobKey(jobName, groupName);
230+
JobDetail jobDetail = this.scheduler.getJobDetail(jobKey);
231+
if (jobDetail == null) {
232+
return null;
233+
}
234+
if (jobData != null) {
235+
this.scheduler.triggerJob(jobKey, new JobDataMap(jobData));
236+
}
237+
else {
238+
this.scheduler.triggerJob(jobKey);
239+
}
240+
return new QuartzJobTriggerDescriptor(jobDetail.getKey().getGroup(), jobDetail.getKey().getName(),
241+
jobDetail.getJobClass().getName(), Instant.now());
242+
}
243+
215244
private static List<Map<String, Object>> extractTriggersSummary(List<? extends Trigger> triggers) {
216245
List<Trigger> triggersToSort = new ArrayList<>(triggers);
217246
triggersToSort.sort(TRIGGER_COMPARATOR);
@@ -387,6 +416,44 @@ public String getClassName() {
387416

388417
}
389418

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

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

+17-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.
@@ -16,6 +16,7 @@
1616

1717
package org.springframework.boot.actuate.quartz;
1818

19+
import java.util.Map;
1920
import java.util.Set;
2021

2122
import org.quartz.SchedulerException;
@@ -27,6 +28,7 @@
2728
import org.springframework.boot.actuate.endpoint.Show;
2829
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
2930
import org.springframework.boot.actuate.endpoint.annotation.Selector;
31+
import org.springframework.boot.actuate.endpoint.annotation.WriteOperation;
3032
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
3133
import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExtension;
3234
import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzGroupsDescriptor;
@@ -35,6 +37,7 @@
3537
import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzTriggerGroupSummaryDescriptor;
3638
import org.springframework.boot.actuate.quartz.QuartzEndpointWebExtension.QuartzEndpointWebExtensionRuntimeHints;
3739
import org.springframework.context.annotation.ImportRuntimeHints;
40+
import org.springframework.lang.Nullable;
3841

3942
/**
4043
* {@link EndpointWebExtension @EndpointWebExtension} for the {@link QuartzEndpoint}.
@@ -79,6 +82,19 @@ public WebEndpointResponse<Object> quartzJobOrTrigger(SecurityContext securityCo
7982
() -> this.delegate.quartzTrigger(group, name, showUnsanitized));
8083
}
8184

85+
@WriteOperation
86+
public WebEndpointResponse<Object> triggerQuartzJob(@Selector String jobs, @Selector String group,
87+
@Selector String name, @Selector String action, @Nullable Map<String, Object> jobData)
88+
throws SchedulerException {
89+
if (!"jobs".equals(jobs)) {
90+
return new WebEndpointResponse<>(WebEndpointResponse.STATUS_BAD_REQUEST);
91+
}
92+
if (!"trigger".equals(action)) {
93+
return new WebEndpointResponse<>(WebEndpointResponse.STATUS_BAD_REQUEST);
94+
}
95+
return handleNull(this.delegate.triggerQuartzJob(group, name, jobData));
96+
}
97+
8298
private <T> WebEndpointResponse<T> handle(String jobsOrTriggers, ResponseSupplier<T> jobAction,
8399
ResponseSupplier<T> triggerAction) throws SchedulerException {
84100
if ("jobs".equals(jobsOrTriggers)) {

0 commit comments

Comments
 (0)