Add the ability to trigger a Quartz job through an Actuator endpoint

Add new methods to `QuartzEndpoint` and `QuartzEndpointWebExtension`
to allow a Quartz job to be triggered on demand.

See gh-43086

Signed-off-by: Dmytro Nosan <dimanosan@gmail.com>
This commit is contained in:
Dmytro Nosan 2025-01-30 17:03:26 +02:00 committed by Phillip Webb
parent ddc45ea8ca
commit fbeace397f
8 changed files with 288 additions and 6 deletions

View File

@ -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

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2024 the original author or authors.
* Copyright 2012-2025 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.
@ -24,6 +24,7 @@ import java.util.Collections;
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;
@ -54,9 +55,11 @@ import org.quartz.spi.OperableTrigger;
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;
@ -68,8 +71,12 @@ import static org.assertj.core.api.Assertions.assertThat;
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;
@ -385,6 +392,23 @@ class QuartzEndpointDocumentationTests extends MockMvcEndpointDocumentationTests
.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")

View File

@ -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;
@ -212,6 +213,26 @@ public class QuartzEndpoint {
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);
@ -387,6 +408,44 @@ public class QuartzEndpoint {
}
/**
* 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}.
*/

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2022 the original author or authors.
* Copyright 2012-2025 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.
@ -27,6 +27,7 @@ import org.springframework.boot.actuate.endpoint.SecurityContext;
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;
@ -79,6 +80,18 @@ public class QuartzEndpointWebExtension {
() -> 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)) {

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2025 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.
@ -66,6 +66,7 @@ import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzDescriptor;
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;
@ -73,9 +74,12 @@ 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}.
@ -755,6 +759,31 @@ class QuartzEndpointTests {
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) {

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2025 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.
@ -20,6 +20,7 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import net.minidev.json.JSONArray;
@ -42,10 +43,12 @@ import org.quartz.TriggerBuilder;
import org.quartz.TriggerKey;
import org.quartz.impl.matchers.GroupMatcher;
import org.springframework.boot.actuate.endpoint.ApiVersion;
import org.springframework.boot.actuate.endpoint.Show;
import org.springframework.boot.actuate.endpoint.web.test.WebEndpointTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.scheduling.quartz.DelegatingJob;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.util.LinkedMultiValueMap;
@ -62,6 +65,10 @@ import static org.mockito.Mockito.mock;
*/
class QuartzEndpointWebIntegrationTests {
private static final String V2_JSON = ApiVersion.V2.getProducedMimeType().toString();
private static final String V3_JSON = ApiVersion.V3.getProducedMimeType().toString();
private static final JobDetail jobOne = JobBuilder.newJob(Job.class)
.withIdentity("jobOne", "samples")
.usingJobData(new JobDataMap(Collections.singletonMap("name", "test")))
@ -249,6 +256,92 @@ class QuartzEndpointWebIntegrationTests {
client.get().uri("/actuator/quartz/triggers/tests/does-not-exist").exchange().expectStatus().isNotFound();
}
@WebEndpointTest
void quartzTriggerJob(WebTestClient client) {
client.post()
.uri("/actuator/quartz/jobs/samples/jobOne")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("state", "running"))
.exchange()
.expectStatus()
.isOk()
.expectBody()
.jsonPath("group")
.isEqualTo("samples")
.jsonPath("name")
.isEqualTo("jobOne")
.jsonPath("className")
.isEqualTo("org.quartz.Job")
.jsonPath("triggerTime")
.isNotEmpty();
}
@WebEndpointTest
void quartzTriggerJobV2(WebTestClient client) {
client.post()
.uri("/actuator/quartz/jobs/samples/jobOne")
.contentType(MediaType.parseMediaType(V2_JSON))
.accept(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("state", "running"))
.exchange()
.expectStatus()
.isOk()
.expectBody()
.jsonPath("group")
.isEqualTo("samples")
.jsonPath("name")
.isEqualTo("jobOne")
.jsonPath("className")
.isEqualTo("org.quartz.Job")
.jsonPath("triggerTime")
.isNotEmpty();
}
@WebEndpointTest
void quartzTriggerJobV3(WebTestClient client) {
client.post()
.uri("/actuator/quartz/jobs/samples/jobOne")
.contentType(MediaType.parseMediaType(V3_JSON))
.accept(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("state", "running"))
.exchange()
.expectStatus()
.isOk()
.expectBody()
.jsonPath("group")
.isEqualTo("samples")
.jsonPath("name")
.isEqualTo("jobOne")
.jsonPath("className")
.isEqualTo("org.quartz.Job")
.jsonPath("triggerTime")
.isNotEmpty();
}
@WebEndpointTest
void quartzTriggerJobWithUnknownJobKey(WebTestClient client) {
client.post()
.uri("/actuator/quartz/jobs/samples/does-not-exist")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("state", "running"))
.exchange()
.expectStatus()
.isNotFound();
}
@WebEndpointTest
void quartzTriggerJobWithUnknownState(WebTestClient client) {
client.post()
.uri("/actuator/quartz/jobs/samples/jobOne")
.contentType(MediaType.parseMediaType(V3_JSON))
.accept(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("state", "unknown"))
.exchange()
.expectStatus()
.isBadRequest();
}
@Configuration(proxyBeanMethods = false)
static class TestConfiguration {

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2025 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.
@ -58,6 +58,15 @@ public class SampleQuartzApplication {
.build();
}
@Bean
public JobDetail onDemandJobDetail() {
return JobBuilder.newJob(SampleJob.class)
.withIdentity("onDemandJob", "samples")
.usingJobData("name", "On Demand Job")
.storeDurably()
.build();
}
@Bean
public Trigger everyTwoSecTrigger() {
return TriggerBuilder.newTrigger()

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2025 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.
@ -16,22 +16,29 @@
package smoketest.quartz;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import org.assertj.core.api.InstanceOfAssertFactories;
import org.assertj.core.api.InstanceOfAssertFactory;
import org.assertj.core.api.MapAssert;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.entry;
import static org.assertj.core.api.Assertions.within;
/**
* Web tests for {@link SampleQuartzApplication}.
@ -39,6 +46,7 @@ import static org.assertj.core.api.Assertions.entry;
* @author Stephane Nicoll
*/
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@ExtendWith(OutputCaptureExtension.class)
class SampleQuartzApplicationWebTests {
@Autowired
@ -91,6 +99,20 @@ class SampleQuartzApplicationWebTests {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void quartzJobTriggeredManually(CapturedOutput output) {
ResponseEntity<Map<String, Object>> result = asMapEntity(this.restTemplate.postForEntity(
"/actuator/quartz/jobs/samples/onDemandJob", new HttpEntity<>(Map.of("state", "running")), Map.class));
assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> content = result.getBody();
assertThat(content).contains(entry("group", "samples"), entry("name", "onDemandJob"),
entry("className", SampleJob.class.getName()));
assertThat(content).extractingByKey("triggerTime", InstanceOfAssertFactories.STRING)
.satisfies((triggerTime) -> assertThat(Instant.parse(triggerTime)).isCloseTo(Instant.now(),
within(10, ChronoUnit.SECONDS)));
assertThat(output).contains("Hello On Demand Job");
}
private Map<String, Object> getContent(String path) {
ResponseEntity<Map<String, Object>> entity = asMapEntity(this.restTemplate.getForEntity(path, Map.class));
assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK);