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..bca16ca01103 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 @@ -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.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 a858b375fd6e..be5cb27379ba 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..406aed760faf 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-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.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..ccaca16e1ce2 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-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.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..4a46fc072005 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-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.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"))) @@ -249,6 +256,92 @@ void quartzTriggerDetailWithUnknownKey(WebTestClient client) { 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 { diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/src/main/java/smoketest/quartz/SampleQuartzApplication.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/src/main/java/smoketest/quartz/SampleQuartzApplication.java index 3013acc99dc1..2f858d1466b5 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/src/main/java/smoketest/quartz/SampleQuartzApplication.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/src/main/java/smoketest/quartz/SampleQuartzApplication.java @@ -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 JobDetail anotherJobDetail() { .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() diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/src/test/java/smoketest/quartz/SampleQuartzApplicationWebTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/src/test/java/smoketest/quartz/SampleQuartzApplicationWebTests.java index 80dafc706e5b..b2f5e49440ec 100644 --- a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/src/test/java/smoketest/quartz/SampleQuartzApplicationWebTests.java +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-quartz/src/test/java/smoketest/quartz/SampleQuartzApplicationWebTests.java @@ -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 @@ * @author Stephane Nicoll */ @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@ExtendWith(OutputCaptureExtension.class) class SampleQuartzApplicationWebTests { @Autowired @@ -91,6 +99,20 @@ void quartzTriggerDetailWhenNameDoesNotExistReturns404() { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); } + @Test + void quartzJobTriggeredManually(CapturedOutput output) { + ResponseEntity> 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 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 getContent(String path) { ResponseEntity> entity = asMapEntity(this.restTemplate.getForEntity(path, Map.class)); assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK);