Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add the ability to trigger a Quartz job on-demand through an Actuator endpoint #43086

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,28 @@ 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}/trigger`, 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.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 @@ -385,6 +385,17 @@ void quartzTriggerCustom() throws Exception {
.andWithPrefix("custom.", customTriggerSummary)));
}

@Test
void quartzTriggerJob() throws Exception {
mockJobs(jobOne);
assertThat(this.mvc.post().uri("/actuator/quartz/jobs/samples/jobOne/trigger")).hasStatusOk()
.apply(document("quartz/trigger-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, @Selector String action) 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));
}

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
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 @@ -249,6 +249,42 @@ 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/trigger")
.exchange()
.expectStatus()
.isOk()
.expectBody()
.jsonPath("group")
.isEqualTo("samples")
.jsonPath("name")
.isEqualTo("jobOne")
.jsonPath("className")
.isEqualTo("org.quartz.Job")
.jsonPath("triggerTime")
.isNotEmpty();
}

@WebEndpointTest
void quartzTriggerJobWithUnknownKey(WebTestClient client) {
client.post()
.uri("/actuator/quartz/jobs/samples/does-not-exist/trigger")
.exchange()
.expectStatus()
.isNotFound();
}

@WebEndpointTest
void quartzTriggerJobWithInvalidAction(WebTestClient client) {
client.post()
.uri("/actuator/quartz/jobs/samples/jobOne/invalid-action")
.exchange()
.expectStatus()
.isBadRequest();
}

@Configuration(proxyBeanMethods = false)
static class TestConfiguration {

Expand Down
Loading