From e6ca4ec418b56bbf4edb15333f185800a40de340 Mon Sep 17 00:00:00 2001 From: Dmytro Nosan Date: Mon, 18 Nov 2024 18:55:03 +0200 Subject: [PATCH] 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}, allowing a job to be triggered by specifying the jobName and groupName See gh-42530 --- .../api/pages/rest/actuator/quartz.adoc | 33 ++++++ .../QuartzEndpointDocumentationTests.java | 24 ++++ .../boot/actuate/quartz/QuartzEndpoint.java | 59 ++++++++++ .../quartz/QuartzEndpointWebExtension.java | 15 ++- .../actuate/quartz/QuartzEndpointTests.java | 31 ++++- .../QuartzEndpointWebIntegrationTests.java | 106 ++++++++++++++---- 6 files changed, 242 insertions(+), 26 deletions(-) diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/antora/modules/api/pages/rest/actuator/quartz.adoc b/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/antora/modules/api/pages/rest/actuator/quartz.adoc index 9bb74a5f26fe..ebc9bcd35794 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/antora/modules/api/pages/rest/actuator/quartz.adoc +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/antora/modules/api/pages/rest/actuator/quartz.adoc @@ -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 diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/QuartzEndpointDocumentationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/QuartzEndpointDocumentationTests.java index 4f8a04060040..5b92d2fe869c 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/QuartzEndpointDocumentationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/QuartzEndpointDocumentationTests.java @@ -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; @@ -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; @@ -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; @@ -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 void setupTriggerDetails(TriggerBuilder builder, TriggerState state) throws SchedulerException { T trigger = builder.withIdentity("example", "samples") diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/QuartzEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/QuartzEndpoint.java index e6cdde6920d2..369a6d568990 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/QuartzEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/QuartzEndpoint.java @@ -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 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> extractTriggersSummary(List triggers) { List triggersToSort = new ArrayList<>(triggers); triggersToSort.sort(TRIGGER_COMPARATOR); @@ -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}. */ diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/QuartzEndpointWebExtension.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/QuartzEndpointWebExtension.java index c5d3ac3e0d0f..0d6cbe6888b8 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/QuartzEndpointWebExtension.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/QuartzEndpointWebExtension.java @@ -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. @@ -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; @@ -79,6 +80,18 @@ public WebEndpointResponse quartzJobOrTrigger(SecurityContext securityCo () -> this.delegate.quartzTrigger(group, name, showUnsanitized)); } + @WriteOperation + public WebEndpointResponse 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 WebEndpointResponse handle(String jobsOrTriggers, ResponseSupplier jobAction, ResponseSupplier triggerAction) throws SchedulerException { if ("jobs".equals(jobsOrTriggers)) { diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/quartz/QuartzEndpointTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/quartz/QuartzEndpointTests.java index 9fa95ed84ff0..002d37185b1d 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/quartz/QuartzEndpointTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/quartz/QuartzEndpointTests.java @@ -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. @@ -66,6 +66,7 @@ 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 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 @@ 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 jobKeys = new LinkedMultiValueMap<>(); for (JobDetail jobDetail : jobs) { diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/quartz/QuartzEndpointWebIntegrationTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/quartz/QuartzEndpointWebIntegrationTests.java index 907224e33cc3..81e72bce7d97 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/quartz/QuartzEndpointWebIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/quartz/QuartzEndpointWebIntegrationTests.java @@ -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. @@ -20,6 +20,7 @@ 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.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 @@ */ 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"))) @@ -215,38 +222,89 @@ void quartzJobDetailWithUnknownKey(WebTestClient client) { } @WebEndpointTest - void quartzTriggerDetail(WebTestClient client) { - client.get() - .uri("/actuator/quartz/triggers/DEFAULT/triggerOne") + 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("DEFAULT") + .isEqualTo("samples") .jsonPath("name") - .isEqualTo("triggerOne") - .jsonPath("description") - .isEqualTo("Once a day 3AM") - .jsonPath("state") - .isEqualTo("NORMAL") - .jsonPath("type") - .isEqualTo("cron") - .jsonPath("simple") - .doesNotExist() - .jsonPath("calendarInterval") - .doesNotExist() - .jsonPath("dailyInterval") - .doesNotExist() - .jsonPath("custom") - .doesNotExist() - .jsonPath("cron.expression") - .isEqualTo("0 0 3 ? * *"); + .isEqualTo("jobOne") + .jsonPath("className") + .isEqualTo("org.quartz.Job") + .jsonPath("triggerTime") + .isNotEmpty(); } @WebEndpointTest - void quartzTriggerDetailWithUnknownKey(WebTestClient client) { - client.get().uri("/actuator/quartz/triggers/tests/does-not-exist").exchange().expectStatus().isNotFound(); + 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)