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},
allowing a job to be triggered by specifying the jobName and groupName

See gh-42530
  • Loading branch information
nosan committed Nov 20, 2024
1 parent 2cc4b02 commit e6ca4ec
Show file tree
Hide file tree
Showing 6 changed files with 242 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,39 @@ The following table describes the structure of the response:
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}`, as shown in the following curl-based example:

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

The preceding example demonstrates how to trigger a job that belongs to the `samples` group and is named `jobOne`.

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 a desired `state` associated with a particular job.
Sending an HTTP Request with a `"state": "running"` body indicates that the job should be run now.
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
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,8 +71,12 @@
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.relaxedResponseFields;
import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath;

Expand Down Expand Up @@ -385,6 +392,23 @@ void quartzTriggerCustom() throws Exception {
.andWithPrefix("custom.", customTriggerSummary)));
}

@Test
void quartzTriggerJob() throws Exception {
mockJobs(jobOne);
String json = JsonWriter.standard().writeToString(Map.of("state", "running"));
assertThat(this.mvc.post()
.content(json)
.contentType(MediaType.APPLICATION_JSON)
.uri("/actuator/quartz/jobs/samples/jobOne"))
.hasStatusOk()
.apply(document("quartz/trigger-job", preprocessRequest(), preprocessResponse(prettyPrint()),
requestFields(fieldWithPath("state").description("The desired state of the job.")),
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 @@ -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,26 @@ public QuartzJobDetailsDescriptor quartzJob(String groupName, String jobName, bo
return null;
}

/**
* Triggers (execute it now) a Quartz job by its group and job name.
* @param groupName the name of the job's group
* @param jobName the name of the job
* @return a description of the triggered job or {@code null} if the job does not
* exist
* @throws SchedulerException if there is an error triggering the job
* @since 3.5.0
*/
public QuartzJobTriggerDescriptor triggerQuartzJob(String groupName, String jobName) throws SchedulerException {
JobKey jobKey = JobKey.jobKey(jobName, groupName);
JobDetail jobDetail = this.scheduler.getJobDetail(jobKey);
if (jobDetail == null) {
return null;
}
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 +408,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 Down Expand Up @@ -27,6 +27,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 Down Expand Up @@ -79,6 +80,18 @@ 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, String state) throws SchedulerException {
if (!"jobs".equals(jobs)) {
return new WebEndpointResponse<>(WebEndpointResponse.STATUS_BAD_REQUEST);
}
if (!"running".equals(state)) {
return new WebEndpointResponse<>(WebEndpointResponse.STATUS_BAD_REQUEST);
}
return handleNull(this.delegate.triggerQuartzJob(group, name));
}

private <T> WebEndpointResponse<T> handle(String jobsOrTriggers, ResponseSupplier<T> jobAction,
ResponseSupplier<T> triggerAction) throws SchedulerException {
if ("jobs".equals(jobsOrTriggers)) {
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 @@ -66,16 +66,20 @@
import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzJobDetailsDescriptor;
import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzJobGroupSummaryDescriptor;
import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzJobSummaryDescriptor;
import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzJobTriggerDescriptor;
import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzTriggerGroupSummaryDescriptor;
import org.springframework.scheduling.quartz.DelegatingJob;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.entry;
import static org.assertj.core.api.Assertions.within;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;

/**
* Tests for {@link QuartzEndpoint}.
Expand Down Expand Up @@ -755,6 +759,31 @@ void quartzJobWithDataMapAndShowUnsanitizedFalse() throws SchedulerException {
entry("url", "******"));
}

@Test
void quartzJobShouldBeTriggered() throws SchedulerException {
JobDetail job = JobBuilder.newJob(Job.class)
.withIdentity("hello", "samples")
.withDescription("A sample job")
.storeDurably()
.requestRecovery(false)
.build();
mockJobs(job);
QuartzJobTriggerDescriptor quartzJobTriggerDescriptor = this.endpoint.triggerQuartzJob("samples", "hello");
assertThat(quartzJobTriggerDescriptor).isNotNull();
assertThat(quartzJobTriggerDescriptor.getName()).isEqualTo("hello");
assertThat(quartzJobTriggerDescriptor.getGroup()).isEqualTo("samples");
assertThat(quartzJobTriggerDescriptor.getClassName()).isEqualTo("org.quartz.Job");
assertThat(quartzJobTriggerDescriptor.getTriggerTime()).isCloseTo(Instant.now(), within(5, ChronoUnit.SECONDS));
then(this.scheduler).should().triggerJob(new JobKey("hello", "samples"));
}

@Test
void quartzJobShouldNotBeTriggeredJobDoesNotExist() throws SchedulerException {
QuartzJobTriggerDescriptor quartzJobTriggerDescriptor = this.endpoint.triggerQuartzJob("samples", "hello");
assertThat(quartzJobTriggerDescriptor).isNull();
then(this.scheduler).should(never()).triggerJob(any());
}

private void mockJobs(JobDetail... jobs) throws SchedulerException {
MultiValueMap<String, JobKey> jobKeys = new LinkedMultiValueMap<>();
for (JobDetail jobDetail : jobs) {
Expand Down
Loading

0 comments on commit e6ca4ec

Please sign in to comment.