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,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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're not fan of this approach and we don't want the action to be part of the URL. We've brainstormed quite a bit and we believe something else is missing that I am going to tackle in #43226.

Rather than this, we'd like an approach where a POST on the job detail could trigger a new execution if the payload contains "running" : true. Can you please review the PR in that direction? It doesn't need the related issue but we'll process them both once they're ready to be merged.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, @snicoll.

Initially, I was thinking of including action=TRIGGER|RUN|EXECUTE as part of the request body.

I have one more question regarding running:true. What should be returned if someone provides running:false? Should it result in a bad request or a not-found response?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about action because quartz supports pause, resume actions a well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feature might be requested in the future. For instance, if a job is broken or encountering issues, someone might want the ability to pause it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea is that you POST a body indicating the desired state. For a job, posting running: true indicates that the job should be run now. AFAIK, Quartz doesn't support cancellation of a running job so posting running:false would be a bad request.

AFAIK, pausing is supported by triggers not jobs so I'd expect that support to be added to a resource that represents a trigger. You'd post something like "state": "paused" to pause a trigger and "state": "normal" to resume it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIK, pausing is supported by triggers not jobs so I'd expect that support to be added to a resource that represents a trigger. You'd post something like "state": "paused" to pause a trigger and "state": "normal" to resume it.

    /**
    * Pause the <code>{@link org.quartz.JobDetail}</code> with the given
    * key - by pausing all of its current <code>Trigger</code>s.
    * 
    * @see #resumeJob(JobKey)
    */
   void pauseJob(JobKey jobKey)
       throws SchedulerException;
       
           /**
    * Resume (un-pause) the <code>{@link org.quartz.JobDetail}</code> with
    * the given key.
    * 
    * <p>
    * If any of the <code>Job</code>'s<code>Trigger</code> s missed one
    * or more fire-times, then the <code>Trigger</code>'s misfire
    * instruction will be applied.
    * </p>
    * 
    * @see #pauseJob(JobKey)
    */
   void resumeJob(JobKey jobKey)
       throws SchedulerException;
       

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, @wilkinsona

just to clarify one more time there should be POST /actuator/quartz/jobs/{groupName}/{jobName} with the JSON body

{
"running": true
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I'd missed the API for pausing and resuming a job. It's an alias for pausing/resuming all of a job's triggers and doesn't affect the state of the job itself, just its associated triggers. I'm not sure how we might add similar functionality to the Quartz endpoint. Posting something like state: paused to a job doesn't feel right to me given that it's actually the triggers' state that would change. It's also not clear to me what a job's state would be if some of its triggers were paused and others were running as normal.

just to clarify one more time…

Yes, that's the preferred direction right now. I'm not totally sure about "running": "true" vs something like "state": "running". The latter feels like it would provide more scope for changes in the future but it should be relatively easy to change the exact payload once the general structure's in place.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest, I do like "state": "running" more than "running": "true"

@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