Skip to content

Commit

Permalink
Add the ability to trigger a Quartz job on-demand through an Actuator…
Browse files Browse the repository at this point in the history
… 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 gh-42530
  • Loading branch information
nosan committed Nov 14, 2024
1 parent 5a7e632 commit 8dbd473
Show file tree
Hide file tree
Showing 10 changed files with 324 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,40 @@ include::partial$rest/actuator/quartz/job-details/response-fields.adoc[]



[[quartz.trigger-job]]
== Trigger Quartz Job On Demand

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:

include::partial$rest/actuator/quartz/trigger-job/curl-request.adoc[]

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.

The response will look similar to the following:

include::partial$rest/actuator/quartz/trigger-job/http-response.adoc[]


[[quartz.trigger-job.request-structure]]
=== Request Structure

The request specifies an optional `jobData` associated with a particular job. The following table describes the structure of the request:

[cols="2,1,3"]
include::partial$rest/actuator/quartz/trigger-job/request-fields.adoc[]


[[quartz.trigger-job.response-structure]]
=== Response Structure

The response contains the details of a triggered job.
The following table describes the structure of the response:

[cols="2,1,3"]
include::partial$rest/actuator/quartz/trigger-job/response-fields.adoc[]



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

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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.
Expand Down Expand Up @@ -147,12 +147,12 @@ private static class SecureReactiveWebOperation implements ReactiveWebOperation
}

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

private Mono<ResponseEntity<Object>> flatMapResponse(ServerWebExchange exchange, Map<String, String> body,
private Mono<ResponseEntity<Object>> flatMapResponse(ServerWebExchange exchange, Map<String, Object> body,
SecurityResponse securityResponse) {
if (!securityResponse.getStatus().equals(HttpStatus.OK)) {
return Mono.just(new ResponseEntity<>(securityResponse.getStatus()));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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.
Expand Down Expand Up @@ -155,7 +155,7 @@ private static class SecureServletWebOperation implements ServletWebOperation {
}

@Override
public Object handle(HttpServletRequest request, Map<String, String> body) {
public Object handle(HttpServletRequest request, Map<String, Object> body) {
SecurityResponse securityResponse = this.securityInterceptor.preHandle(request, this.endpointId);
if (!securityResponse.getStatus().equals(HttpStatus.OK)) {
return new ResponseEntity<Object>(securityResponse.getMessage(), securityResponse.getStatus());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.TimeZone;

Expand Down Expand Up @@ -54,9 +55,11 @@
import org.springframework.boot.actuate.endpoint.Show;
import org.springframework.boot.actuate.quartz.QuartzEndpoint;
import org.springframework.boot.actuate.quartz.QuartzEndpointWebExtension;
import org.springframework.boot.json.JsonWriter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.restdocs.payload.FieldDescriptor;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.scheduling.quartz.DelegatingJob;
Expand All @@ -68,7 +71,11 @@
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.relaxedRequestFields;
import static org.springframework.restdocs.payload.PayloadDocumentation.relaxedResponseFields;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath;
Expand Down Expand Up @@ -385,6 +392,28 @@ void quartzTriggerCustom() throws Exception {
.andWithPrefix("custom.", customTriggerSummary)));
}

@Test
void quartzTriggerJob() throws Exception {
mockJobs(jobOne);
String json = JsonWriter.<Map<String, Object>>of((members) -> members.addMapEntries(Map::copyOf))
.write(Map.of("jobData", Map.of("key", "value", "keyN", "valueN")))
.toJsonString();
assertThat(this.mvc.post()
.uri("/actuator/quartz/jobs/samples/jobOne/trigger")
.content(json)
.contentType(MediaType.APPLICATION_JSON))
.hasStatusOk()
.apply(document("quartz/trigger-job", preprocessRequest(), preprocessResponse(prettyPrint()),
relaxedRequestFields(fieldWithPath("jobData").description(
"A Quartz key-value JobDataMap, that will be associated with the trigger, that fires the job immediately."),
fieldWithPath("jobData.key")
.description("An arbitrary name that will be used as a key in JobDataMap.")),
responseFields(fieldWithPath("group").description("Name of the group."),
fieldWithPath("name").description("Name of the job."),
fieldWithPath("className").description("Fully qualified name of the job implementation."),
fieldWithPath("triggerTime").description("Time the job is triggered."))));
}

private <T extends Trigger> void setupTriggerDetails(TriggerBuilder<T> builder, TriggerState state)
throws SchedulerException {
T trigger = builder.withIdentity("example", "samples")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ protected interface LinksHandler {
@FunctionalInterface
protected interface ReactiveWebOperation {

Mono<ResponseEntity<Object>> handle(ServerWebExchange exchange, Map<String, String> body);
Mono<ResponseEntity<Object>> handle(ServerWebExchange exchange, Map<String, Object> body);

}

Expand Down Expand Up @@ -349,7 +349,7 @@ Mono<SecurityContext> emptySecurityContext() {
}

@Override
public Mono<ResponseEntity<Object>> handle(ServerWebExchange exchange, Map<String, String> body) {
public Mono<ResponseEntity<Object>> handle(ServerWebExchange exchange, Map<String, Object> body) {
Map<String, Object> arguments = getArguments(exchange, body);
OperationArgumentResolver serverNamespaceArgumentResolver = OperationArgumentResolver
.of(WebServerNamespace.class, () -> WebServerNamespace
Expand All @@ -363,7 +363,7 @@ public Mono<ResponseEntity<Object>> handle(ServerWebExchange exchange, Map<Strin
exchange.getRequest().getMethod()));
}

private Map<String, Object> getArguments(ServerWebExchange exchange, Map<String, String> body) {
private Map<String, Object> getArguments(ServerWebExchange exchange, Map<String, Object> body) {
Map<String, Object> arguments = new LinkedHashMap<>(getTemplateVariables(exchange));
String matchAllRemainingPathSegmentsVariable = this.operation.getRequestPredicate()
.getMatchAllRemainingPathSegmentsVariable();
Expand Down Expand Up @@ -448,7 +448,7 @@ private static final class WriteOperationHandler {
@ResponseBody
@Reflective
Publisher<ResponseEntity<Object>> handle(ServerWebExchange exchange,
@RequestBody(required = false) Map<String, String> body) {
@RequestBody(required = false) Map<String, Object> body) {
return this.operation.handle(exchange, body);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ protected interface LinksHandler {
@FunctionalInterface
protected interface ServletWebOperation {

Object handle(HttpServletRequest request, Map<String, String> body);
Object handle(HttpServletRequest request, Map<String, Object> body);

}

Expand Down Expand Up @@ -308,7 +308,7 @@ private static class ServletWebOperationAdapter implements ServletWebOperation {
}

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

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

@ResponseBody
@Reflective
Object handle(HttpServletRequest request, @RequestBody(required = false) Map<String, String> body) {
Object handle(HttpServletRequest request, @RequestBody(required = false) Map<String, Object> body) {
return this.operation.handle(request, body);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package org.springframework.boot.actuate.quartz;

import java.time.Duration;
import java.time.Instant;
import java.time.LocalTime;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalUnit;
Expand Down Expand Up @@ -212,6 +213,33 @@ public QuartzJobDetailsDescriptor quartzJob(String groupName, String jobName, bo
return null;
}

/**
* Trigger (execute it now) the job identified with the given group name and job name.
* @param groupName the name of the group
* @param jobName the name of the job
* @param jobData the job jobData, or {@code null}
* @return the description of the job or {@code null} if such job does not exist
* @throws SchedulerException if either triggering job or retrieving the information
* from the scheduler failed
* @since 3.5.0
*/
public QuartzJobTriggerDescriptor triggerQuartzJob(String groupName, String jobName, Map<String, Object> jobData)
throws SchedulerException {
JobKey jobKey = JobKey.jobKey(jobName, groupName);
JobDetail jobDetail = this.scheduler.getJobDetail(jobKey);
if (jobDetail == null) {
return null;
}
if (jobData != null) {
this.scheduler.triggerJob(jobKey, new JobDataMap(jobData));
}
else {
this.scheduler.triggerJob(jobKey);
}
return new QuartzJobTriggerDescriptor(jobDetail.getKey().getGroup(), jobDetail.getKey().getName(),
jobDetail.getJobClass().getName(), Instant.now());
}

private static List<Map<String, Object>> extractTriggersSummary(List<? extends Trigger> triggers) {
List<Trigger> triggersToSort = new ArrayList<>(triggers);
triggersToSort.sort(TRIGGER_COMPARATOR);
Expand Down Expand Up @@ -387,6 +415,44 @@ public String getClassName() {

}

/**
* Description of a triggered on demand {@link Job Quartz Job}.
*/
public static final class QuartzJobTriggerDescriptor {

private final String group;

private final String name;

private final String className;

private final Instant triggerTime;

private QuartzJobTriggerDescriptor(String group, String name, String className, Instant triggerTime) {
this.group = group;
this.name = name;
this.className = className;
this.triggerTime = triggerTime;
}

public String getGroup() {
return this.group;
}

public String getName() {
return this.name;
}

public String getClassName() {
return this.className;
}

public Instant getTriggerTime() {
return this.triggerTime;
}

}

/**
* Description of a {@link Job Quartz Job}.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2012-2022 the original author or authors.
* Copyright 2012-2024 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.
Expand All @@ -16,6 +16,7 @@

package org.springframework.boot.actuate.quartz;

import java.util.Map;
import java.util.Set;

import org.quartz.SchedulerException;
Expand All @@ -27,6 +28,7 @@
import org.springframework.boot.actuate.endpoint.Show;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.annotation.Selector;
import org.springframework.boot.actuate.endpoint.annotation.WriteOperation;
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExtension;
import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzGroupsDescriptor;
Expand All @@ -35,6 +37,7 @@
import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzTriggerGroupSummaryDescriptor;
import org.springframework.boot.actuate.quartz.QuartzEndpointWebExtension.QuartzEndpointWebExtensionRuntimeHints;
import org.springframework.context.annotation.ImportRuntimeHints;
import org.springframework.lang.Nullable;

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

@WriteOperation
public WebEndpointResponse<Object> triggerQuartzJob(@Selector String jobs, @Selector String group,
@Selector String name, @Selector String action, @Nullable Map<String, Object> jobData)
throws SchedulerException {
if (!"jobs".equals(jobs)) {
return new WebEndpointResponse<>(WebEndpointResponse.STATUS_BAD_REQUEST);
}
if (!"trigger".equals(action)) {
return new WebEndpointResponse<>(WebEndpointResponse.STATUS_BAD_REQUEST);
}
return handleNull(this.delegate.triggerQuartzJob(group, name, jobData));
}

private <T> WebEndpointResponse<T> handle(String jobsOrTriggers, ResponseSupplier<T> jobAction,
ResponseSupplier<T> triggerAction) throws SchedulerException {
if ("jobs".equals(jobsOrTriggers)) {
Expand Down
Loading

0 comments on commit 8dbd473

Please sign in to comment.