Polish "Add Quartz actuator endpoint"

This commit reworks the initial proposal so that jobs and triggers are
treated as first class concepts.

`/actuator/quartz` now returns the group names for jobs and triggers.

`actuator/quartz/jobs` returns the job names, keyed by the available
group names, while `/actuator/quartz/triggers` does the same for
triggers.

`/actuator/jobs/{groupName}` provides an overview of a job group. It
provides a map of job names with the class name of the job.
implementation

`/actuator/triggers/{groupName}` provides an overview of a trigger
group. There are five supported trigger implementations: cron, simple,
daily time interval, calendar interval, and custom for any other
implementation. Given that each implementation has specific settings,
triggers are split in five objects.

`/actuator/jobs/{groupName}/{jobName}` provides the full details of a
particular job. This includes a sanitized data map and a list of
triggers ordered by next fire time.

`/actuator/triggers/{groupName}/{triggerName}` provides the full details
of a particular trigger. This includes the state, its type, and a
dedicate object containing implementation-specific settings.

See gh-10364
pull/30708/head
Stephane Nicoll 4 years ago
parent 9795061360
commit b11602aeaa

@ -0,0 +1,247 @@
[[quartz]]
= Quartz (`quartz`)
The `quartz` endpoint provides information about jobs and triggers that are managed by the Quartz Scheduler.
[[quartz-report]]
== Retrieving Registered Groups
Jobs and triggers are managed in groups.
To retrieve the list of registered job and trigger groups, make a `GET` request to `/actuator/quartz`, as shown in the following curl-based example:
include::{snippets}/quartz/report/curl-request.adoc[]
The resulting response is similar to the following:
include::{snippets}/quartz/report/http-response.adoc[]
[[quartz-report-response-structure]]
=== Response Structure
The response contains the groups names for registered jobs and triggers.
The following table describes the structure of the response:
[cols="3,1,3"]
include::{snippets}/quartz/report/response-fields.adoc[]
[[quartz-job-groups]]
== Retrieving Registered Job Names
To retrieve the list of registered job names, make a `GET` request to `/actuator/quartz/jobs`, as shown in the following curl-based example:
include::{snippets}/quartz/jobs/curl-request.adoc[]
The resulting response is similar to the following:
include::{snippets}/quartz/jobs/http-response.adoc[]
[[quartz-job-groups-response-structure]]
=== Response Structure
The response contains the registered job names for each group.
The following table describes the structure of the response:
[cols="3,1,3"]
include::{snippets}/quartz/jobs/response-fields.adoc[]
[[quartz-trigger-groups]]
== Retrieving Registered Trigger Names
To retrieve the list of registered trigger names, make a `GET` request to `/actuator/quartz/triggers`, as shown in the following curl-based example:
include::{snippets}/quartz/triggers/curl-request.adoc[]
The resulting response is similar to the following:
include::{snippets}/quartz/triggers/http-response.adoc[]
[[quartz-trigger-groups-response-structure]]
=== Response Structure
The response contains the registered trigger names for each group.
The following table describes the structure of the response:
[cols="3,1,3"]
include::{snippets}/quartz/triggers/response-fields.adoc[]
[[quartz-job-group]]
== Retrieving Overview of a Job Group
To retrieve an overview of the jobs in a particular group, make a `GET` request to `/actuator/quartz/jobs/\{groupName}`, as shown in the following curl-based example:
include::{snippets}/quartz/job-group/curl-request.adoc[]
The preceding example retrieves the summary for jobs in the `samples` group.
The resulting response is similar to the following:
include::{snippets}/quartz/job-group/http-response.adoc[]
[[quartz-job-group-response-structure]]
=== Response Structure
The response contains an overview of jobs in a particular group.
The following table describes the structure of the response:
[cols="3,1,3"]
include::{snippets}/quartz/job-group/response-fields.adoc[]
[[quartz-trigger-group]]
== Retrieving Overview of a Trigger Group
To retrieve an overview of the triggers in a particular group, make a `GET` request to `/actuator/quartz/triggers/\{groupName}`, as shown in the following curl-based example:
include::{snippets}/quartz/trigger-group/curl-request.adoc[]
The preceding example retrieves the summary for triggers in the `tests` group.
The resulting response is similar to the following:
include::{snippets}/quartz/trigger-group/http-response.adoc[]
[[quartz-trigger-group-response-structure]]
=== Response Structure
The response contains an overview of triggers in a particular group.
Trigger implementation specific details are available.
The following table describes the structure of the response:
[cols="3,1,3"]
include::{snippets}/quartz/trigger-group/response-fields.adoc[]
[[quartz-job]]
== Retrieving Details of a Job
To retrieve the details about a particular job, make a `GET` request to `/actuator/quartz/jobs/\{groupName}/\{jobName}`, as shown in the following curl-based example:
include::{snippets}/quartz/job-details/curl-request.adoc[]
The preceding example retrieves the details of the job identified by the `samples` group and `jobOne` name.
The resulting response is similar to the following:
include::{snippets}/quartz/job-details/http-response.adoc[]
If a key in the data map is identified as sensitive, its value is sanitized.
[[quartz-job-response-structure]]
=== Response Structure
The response contains the full details of a job including a summary of the triggers associated with it, if any.
The triggers are sorted by next fire time and priority.
The following table describes the structure of the response:
[cols="2,1,3"]
include::{snippets}/quartz/job-details/response-fields.adoc[]
[[quartz-trigger]]
== Retrieving Details of a Trigger
To retrieve the details about a particular trigger, make a `GET` request to `/actuator/quartz/triggers/\{groupName}/\{triggerName}`, as shown in the following curl-based example:
include::{snippets}/quartz/trigger-details-cron/curl-request.adoc[]
The preceding example retrieves the details of trigger identified by the `samples` group and `example` name.
The resulting response has a common structure and a specific additional object according to the trigger implementation.
There are five supported types:
* `cron` for `CronTrigger`
* `simple` for `SimpleTrigger`
* `dailyTimeInterval` for `DailyTimeIntervalTrigger`
* `calendarInterval` for `CalendarIntervalTrigger`
* `custom` for any other trigger implementations
[[quartz-trigger-cron]]
=== Cron Trigger Response Structure
A cron trigger defines the cron expression that is used to determine when it has to fire.
The resulting response for such a trigger implementation is similar to the following:
include::{snippets}/quartz/trigger-details-cron/http-response.adoc[]
The following table describes the structure of the response:
[cols="2,1,3"]
include::{snippets}/quartz/trigger-details-cron/response-fields.adoc[]
[[quartz-trigger-simple]]
=== Simple Trigger Response Structure
A simple trigger is used to fire a Job at a given moment in time, and optionally repeated at a specified interval.
The resulting response for such a trigger implementation is similar to the following:
include::{snippets}/quartz/trigger-details-simple/http-response.adoc[]
The following table describes the structure of the response:
[cols="2,1,3"]
include::{snippets}/quartz/trigger-details-simple/response-fields.adoc[]
[[quartz-trigger-daily-time-interval]]
=== Daily Time Interval Trigger Response Structure
A daily time interval trigger is used to fire a Job based upon daily repeating time intervals.
The resulting response for such a trigger implementation is similar to the following:
include::{snippets}/quartz/trigger-details-daily-time-interval/http-response.adoc[]
The following table describes the structure of the response:
[cols="2,1,3"]
include::{snippets}/quartz/trigger-details-daily-time-interval/response-fields.adoc[]
[[quartz-trigger-calendar-interval]]
=== Calendar Interval Trigger Response Structure
A daily time interval trigger is used to fire a Job based upon repeating calendar time intervals.
The resulting response for such a trigger implementation is similar to the following:
include::{snippets}/quartz/trigger-details-calendar-interval/http-response.adoc[]
The following table describes the structure of the response:
[cols="2,1,3"]
include::{snippets}/quartz/trigger-details-calendar-interval/response-fields.adoc[]
[[quartz-trigger-custom]]
=== Custom Trigger Response Structure
A custom trigger is any other implementation.
The resulting response for such a trigger implementation is similar to the following:
include::{snippets}/quartz/trigger-details-custom/http-response.adoc[]
The following table describes the structure of the response:
[cols="2,1,3"]
include::{snippets}/quartz/trigger-details-custom/response-fields.adoc[]

@ -1,45 +0,0 @@
[[quartz]]
= Quartz (`quartz`)
The `quartz` endpoint provides information about the scheduled jobs that are managed by
Quartz Scheduler.
[[quartz-report]]
== Retrieving the Quartz report
To retrieve the Quartz, make a `GET` request to `/application/quartz`,
as shown in the following curl-based example:
include::{snippets}quartz-report/curl-request.adoc[]
The resulting response is similar to the following:
include::{snippets}quartz-report/http-response.adoc[]
[[quartz-job]]
== Retrieving the Quartz job details
To retrieve the Quartz, make a `GET` request to `/application/quartz/{group}/{name}`,
as shown in the following curl-based example:
include::{snippets}quartz-job/curl-request.adoc[]
The preceding example retrieves the job with the `group` of `groupOne` and `name` of
`jobOne`. The resulting response is similar to the following:
include::{snippets}quartz-job/http-response.adoc[]
[[quartz-job-response-structure]]
=== Response Structure
The response contains details of the scheduled job. The following table describes the
structure of the response:
[cols="2,1,3"]
include::{snippets}quartz-job/response-fields.adoc[]

@ -1,11 +1,11 @@
/*
* Copyright 2012-2017 the original author or authors.
* Copyright 2012-2021 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
@ -20,6 +20,7 @@ import org.quartz.Scheduler;
import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint;
import org.springframework.boot.actuate.quartz.QuartzEndpoint;
import org.springframework.boot.actuate.quartz.QuartzEndpointWebExtension;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
@ -33,9 +34,10 @@ import org.springframework.context.annotation.Configuration;
* {@link EnableAutoConfiguration Auto-configuration} for {@link QuartzEndpoint}.
*
* @author Vedran Pavic
* @since 2.0.0
* @author Stephane Nicoll
* @since 2.5.0
*/
@Configuration
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(Scheduler.class)
@AutoConfigureAfter(QuartzAutoConfiguration.class)
@ConditionalOnAvailableEndpoint(endpoint = QuartzEndpoint.class)
@ -48,4 +50,11 @@ public class QuartzEndpointAutoConfiguration {
return new QuartzEndpoint(scheduler);
}
@Bean
@ConditionalOnBean(QuartzEndpoint.class)
@ConditionalOnMissingBean
public QuartzEndpointWebExtension quartzEndpointWebExtension(QuartzEndpoint endpoint) {
return new QuartzEndpointWebExtension(endpoint);
}
}

@ -1,11 +1,11 @@
/*
* Copyright 2012-2017 the original author or authors.
* Copyright 2012-2021 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,

@ -1,11 +1,11 @@
/*
* Copyright 2012-2017 the original author or authors.
* Copyright 2012-2021 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
@ -17,33 +17,55 @@
package org.springframework.boot.actuate.autoconfigure.endpoint.web.documentation;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.regex.Pattern;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map.Entry;
import java.util.TimeZone;
import org.junit.Test;
import org.junit.jupiter.api.Test;
import org.quartz.CalendarIntervalScheduleBuilder;
import org.quartz.CalendarIntervalTrigger;
import org.quartz.CronScheduleBuilder;
import org.quartz.CronTrigger;
import org.quartz.DailyTimeIntervalScheduleBuilder;
import org.quartz.DailyTimeIntervalTrigger;
import org.quartz.DateBuilder.IntervalUnit;
import org.quartz.Job;
import org.quartz.JobBuilder;
import org.quartz.JobDetail;
import org.quartz.JobKey;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.SimpleScheduleBuilder;
import org.quartz.SimpleTrigger;
import org.quartz.TimeOfDay;
import org.quartz.Trigger;
import org.quartz.Trigger.TriggerState;
import org.quartz.TriggerBuilder;
import org.quartz.TriggerKey;
import org.quartz.impl.matchers.GroupMatcher;
import org.quartz.spi.OperableTrigger;
import org.springframework.boot.actuate.quartz.QuartzEndpoint;
import org.springframework.boot.actuate.quartz.QuartzEndpointWebExtension;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.restdocs.payload.FieldDescriptor;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.scheduling.quartz.DelegatingJob;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
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.preprocessResponse;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.replacePattern;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@ -53,76 +75,374 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
* Tests for generating documentation describing the {@link QuartzEndpoint}.
*
* @author Vedran Pavic
* @author Stephane Nicoll
*/
public class QuartzEndpointDocumentationTests extends MockMvcEndpointDocumentationTests {
class QuartzEndpointDocumentationTests extends MockMvcEndpointDocumentationTests {
private static final JobDetail jobOne = JobBuilder.newJob(Job.class).withIdentity("jobOne", "groupOne")
.withDescription("My first job").build();
private static final TimeZone timeZone = TimeZone.getTimeZone("Europe/Paris");
private static final JobDetail jobTwo = JobBuilder.newJob(Job.class).withIdentity("jobTwo", "groupOne").build();
private static final JobDetail jobOne = JobBuilder.newJob(DelegatingJob.class).withIdentity("jobOne", "samples")
.withDescription("A sample job").usingJobData("user", "admin").usingJobData("password", "secret").build();
private static final JobDetail jobThree = JobBuilder.newJob(Job.class).withIdentity("jobThree", "groupTwo").build();
private static final JobDetail jobTwo = JobBuilder.newJob(Job.class).withIdentity("jobTwo", "samples").build();
private static final Trigger triggerOne = TriggerBuilder.newTrigger().forJob(jobOne).withIdentity("triggerOne")
.withDescription("My first trigger").modifiedByCalendar("myCalendar")
.startAt(Date.from(Instant.parse("2017-12-01T12:00:00Z")))
.endAt(Date.from(Instant.parse("2017-12-01T12:30:00Z")))
.withSchedule(SimpleScheduleBuilder.repeatMinutelyForever()).build();
private static final JobDetail jobThree = JobBuilder.newJob(Job.class).withIdentity("jobThree", "tests").build();
private static final Trigger triggerTwo = TriggerBuilder.newTrigger().forJob(jobOne).withIdentity("triggerTwo")
.withDescription("My second trigger").modifiedByCalendar("myCalendar")
.startAt(Date.from(Instant.parse("2017-12-01T00:00:00Z")))
.endAt(Date.from(Instant.parse("2017-12-10T00:00:00Z")))
.withSchedule(SimpleScheduleBuilder.repeatHourlyForever()).build();
private static final CronTrigger triggerOne = TriggerBuilder.newTrigger().forJob(jobOne).withPriority(3)
.withDescription("3AM on weekdays").withIdentity("3am-weekdays", "samples")
.withSchedule(
CronScheduleBuilder.atHourAndMinuteOnGivenDaysOfWeek(3, 0, 1, 2, 3, 4, 5).inTimeZone(timeZone))
.build();
private static final SimpleTrigger triggerTwo = TriggerBuilder.newTrigger().forJob(jobOne).withPriority(7)
.withDescription("Once a day").withIdentity("every-day", "samples")
.withSchedule(SimpleScheduleBuilder.repeatHourlyForever(24)).build();
private static final CalendarIntervalTrigger triggerThree = TriggerBuilder.newTrigger().forJob(jobTwo)
.withDescription("Once a week").withIdentity("once-a-week", "samples")
.withSchedule(CalendarIntervalScheduleBuilder.calendarIntervalSchedule().withIntervalInWeeks(1)
.inTimeZone(timeZone))
.build();
private static final DailyTimeIntervalTrigger triggerFour = TriggerBuilder.newTrigger().forJob(jobThree)
.withDescription("Every hour between 9AM and 6PM on Tuesday and Thursday")
.withIdentity("every-hour-tue-thu")
.withSchedule(DailyTimeIntervalScheduleBuilder.dailyTimeIntervalSchedule()
.onDaysOfTheWeek(Calendar.TUESDAY, Calendar.THURSDAY)
.startingDailyAt(TimeOfDay.hourAndMinuteOfDay(9, 0))
.endingDailyAt(TimeOfDay.hourAndMinuteOfDay(18, 0)).withInterval(1, IntervalUnit.HOUR))
.build();
private static final List<FieldDescriptor> triggerSummary = Arrays.asList(previousFireTime(""), nextFireTime(""),
priority(""));
private static final List<FieldDescriptor> cronTriggerSummary = Arrays.asList(
fieldWithPath("expression").description("Cron expression to use."),
fieldWithPath("timeZone").type(JsonFieldType.STRING).optional()
.description("Time zone for which the expression will be resolved, if any."));
private static final List<FieldDescriptor> simpleTriggerSummary = Collections
.singletonList(fieldWithPath("interval").description("Interval, in milliseconds, between two executions."));
private static final List<FieldDescriptor> dailyTimeIntervalTriggerSummary = Arrays.asList(
fieldWithPath("interval").description(
"Interval, in milliseconds, added to the fire time in order to calculate the time of the next trigger repeat."),
fieldWithPath("daysOfWeek").type(JsonFieldType.ARRAY)
.description("An array of days of the week upon which to fire."),
fieldWithPath("startTimeOfDay").type(JsonFieldType.STRING)
.description("Time of day to start firing at the given interval, if any."),
fieldWithPath("endTimeOfDay").type(JsonFieldType.STRING)
.description("Time of day to complete firing at the given interval, if any."));
private static final List<FieldDescriptor> calendarIntervalTriggerSummary = Arrays.asList(
fieldWithPath("interval").description(
"Interval, in milliseconds, added to the fire time in order to calculate the time of the next trigger repeat."),
fieldWithPath("timeZone").type(JsonFieldType.STRING)
.description("Time zone within which time calculations will be performed, if any."));
private static final List<FieldDescriptor> customTriggerSummary = Collections.singletonList(
fieldWithPath("trigger").description("A toString representation of the custom trigger instance."));
private static final FieldDescriptor[] commonCronDetails = new FieldDescriptor[] {
fieldWithPath("group").description("Name of the group."),
fieldWithPath("name").description("Name of the trigger."),
fieldWithPath("description").description("Description of the trigger, if any."),
fieldWithPath("state")
.description("State of the trigger, can be NONE, NORMAL, PAUSED, COMPLETE, ERROR, or BLOCKED."),
fieldWithPath("type").description(
"Type of the trigger, determine the key of the object containing implementation-specific details."),
fieldWithPath("calendarName").description("Name of the Calendar associated with this Trigger, if any."),
startTime(""), endTime(""), previousFireTime(""), nextFireTime(""), priority(""),
fieldWithPath("finalFireTime").optional().type(JsonFieldType.STRING)
.description("Last time at which the Trigger will fire, if any."),
fieldWithPath("data").optional().type(JsonFieldType.OBJECT)
.description("Job data map keyed by name, if any.") };
@MockBean
private Scheduler scheduler;
@Test
public void quartzReport() throws Exception {
String groupOne = jobOne.getKey().getGroup();
String groupTwo = jobThree.getKey().getGroup();
given(this.scheduler.getJobGroupNames()).willReturn(Arrays.asList(groupOne, groupTwo));
given(this.scheduler.getJobKeys(GroupMatcher.jobGroupEquals(groupOne)))
.willReturn(new HashSet<>(Arrays.asList(jobOne.getKey(), jobTwo.getKey())));
given(this.scheduler.getJobKeys(GroupMatcher.jobGroupEquals(groupTwo)))
.willReturn(Collections.singleton(jobThree.getKey()));
this.mockMvc.perform(get("/application/quartz")).andExpect(status().isOk()).andDo(document("quartz/report"));
void quartzReport() throws Exception {
mockJobs(jobOne, jobTwo, jobThree);
mockTriggers(triggerOne, triggerTwo, triggerThree, triggerFour);
this.mockMvc.perform(get("/actuator/quartz")).andExpect(status().isOk())
.andDo(document("quartz/report",
responseFields(fieldWithPath("jobs.groups").description("An array of job group names."),
fieldWithPath("triggers.groups").description("An array of trigger group names."))));
}
@Test
void quartzJobs() throws Exception {
mockJobs(jobOne, jobTwo, jobThree);
this.mockMvc.perform(get("/actuator/quartz/jobs")).andExpect(status().isOk()).andDo(
document("quartz/jobs", responseFields(fieldWithPath("groups").description("Job groups keyed by name."),
fieldWithPath("groups.*.jobs").description("An array of job names."))));
}
@Test
void quartzTriggers() throws Exception {
mockTriggers(triggerOne, triggerTwo, triggerThree, triggerFour);
this.mockMvc.perform(get("/actuator/quartz/triggers")).andExpect(status().isOk())
.andDo(document("quartz/triggers",
responseFields(fieldWithPath("groups").description("Trigger groups keyed by name."),
fieldWithPath("groups.*.paused").description("Whether this trigger group is paused."),
fieldWithPath("groups.*.triggers").description("An array of trigger names."))));
}
@Test
void quartzJobGroup() throws Exception {
mockJobs(jobOne, jobTwo, jobThree);
this.mockMvc.perform(get("/actuator/quartz/jobs/samples")).andExpect(status().isOk())
.andDo(document("quartz/job-group",
responseFields(fieldWithPath("group").description("Name of the group."),
fieldWithPath("jobs").description("Job details keyed by name."),
fieldWithPath("jobs.*.className")
.description("Fully qualified name of the job implementation."))));
}
@Test
void quartzTriggerGroup() throws Exception {
CronTrigger cron = triggerOne.getTriggerBuilder().startAt(fromUtc("2020-11-30T17:00:00Z"))
.endAt(fromUtc("2020-12-30T03:00:00Z")).withIdentity("3am-week", "tests").build();
setPreviousNextFireTime(cron, "2020-12-04T03:00:00Z", "2020-12-07T03:00:00Z");
SimpleTrigger simple = triggerTwo.getTriggerBuilder().withIdentity("every-day", "tests").build();
setPreviousNextFireTime(simple, null, "2020-12-04T12:00:00Z");
CalendarIntervalTrigger calendarInterval = triggerThree.getTriggerBuilder().withIdentity("once-a-week", "tests")
.startAt(fromUtc("2019-07-10T14:00:00Z")).endAt(fromUtc("2023-01-01T12:00:00Z")).build();
setPreviousNextFireTime(calendarInterval, "2020-12-02T14:00:00Z", "2020-12-08T14:00:00Z");
DailyTimeIntervalTrigger tueThuTrigger = triggerFour.getTriggerBuilder().withIdentity("tue-thu", "tests")
.build();
Trigger customTrigger = mock(Trigger.class);
given(customTrigger.getKey()).willReturn(TriggerKey.triggerKey("once-a-year-custom", "tests"));
given(customTrigger.toString()).willReturn("com.example.CustomTrigger@fdsfsd");
given(customTrigger.getPriority()).willReturn(10);
given(customTrigger.getPreviousFireTime()).willReturn(fromUtc("2020-07-14T16:00:00Z"));
given(customTrigger.getNextFireTime()).willReturn(fromUtc("2021-07-14T16:00:00Z"));
mockTriggers(cron, simple, calendarInterval, tueThuTrigger, customTrigger);
this.mockMvc.perform(get("/actuator/quartz/triggers/tests")).andExpect(status().isOk()).andDo(document(
"quartz/trigger-group",
responseFields(fieldWithPath("group").description("Name of the group."),
fieldWithPath("paused").description("Whether the group is paused."),
fieldWithPath("triggers.cron").description("Cron triggers keyed by name, if any."),
fieldWithPath("triggers.simple").description("Simple triggers keyed by name, if any."),
fieldWithPath("triggers.dailyTimeInterval")
.description("Daily time interval triggers keyed by name, if any."),
fieldWithPath("triggers.calendarInterval")
.description("Calendar interval triggers keyed by name, if any."),
fieldWithPath("triggers.custom").description("Any other triggers keyed by name, if any."))
.andWithPrefix("triggers.cron.*.", concat(triggerSummary, cronTriggerSummary))
.andWithPrefix("triggers.simple.*.", concat(triggerSummary, simpleTriggerSummary))
.andWithPrefix("triggers.dailyTimeInterval.*.",
concat(triggerSummary, dailyTimeIntervalTriggerSummary))
.andWithPrefix("triggers.calendarInterval.*.",
concat(triggerSummary, calendarIntervalTriggerSummary))
.andWithPrefix("triggers.custom.*.", concat(triggerSummary, customTriggerSummary))));
}
@Test
void quartzJob() throws Exception {
mockJobs(jobOne);
CronTrigger firstTrigger = triggerOne.getTriggerBuilder().build();
setPreviousNextFireTime(firstTrigger, null, "2020-12-07T03:00:00Z");
SimpleTrigger secondTrigger = triggerTwo.getTriggerBuilder().build();
setPreviousNextFireTime(secondTrigger, "2020-12-04T03:00:00Z", "2020-12-04T12:00:00Z");
mockTriggers(firstTrigger, secondTrigger);
given(this.scheduler.getTriggersOfJob(jobOne.getKey()))
.willAnswer((invocation) -> Arrays.asList(firstTrigger, secondTrigger));
this.mockMvc.perform(get("/actuator/quartz/jobs/samples/jobOne")).andExpect(status().isOk()).andDo(document(
"quartz/job-details",
responseFields(fieldWithPath("group").description("Name of the group."),
fieldWithPath("name").description("Name of the job."),
fieldWithPath("description").description("Description of the job, if any."),
fieldWithPath("className").description("Fully qualified name of the job implementation."),
fieldWithPath("durable")
.description("Whether the job should remain stored after it is orphaned."),
fieldWithPath("requestRecovery").description(
"Whether the job should be re-executed if a 'recovery' or 'fail-over' situation is encountered."),
fieldWithPath("data.*").description("Job data map as key/value pairs, if any."),
fieldWithPath("triggers").description("An array of triggers associated to the job, if any."),
fieldWithPath("triggers.[].group").description("Name of the the trigger group."),
fieldWithPath("triggers.[].name").description("Name of the the trigger."),
previousFireTime("triggers.[]."), nextFireTime("triggers.[]."), priority("triggers.[]."))));
}
@Test
void quartzTriggerCron() throws Exception {
setupTriggerDetails(triggerOne.getTriggerBuilder(), TriggerState.NORMAL);
this.mockMvc.perform(get("/actuator/quartz/triggers/samples/example")).andExpect(status().isOk())
.andDo(document("quartz/trigger-details-cron",
responseFields(commonCronDetails)
.and(fieldWithPath("cron").description("Cron trigger specific details."))
.andWithPrefix("cron.", cronTriggerSummary)));
}
@Test
void quartzTriggerSimple() throws Exception {
setupTriggerDetails(triggerTwo.getTriggerBuilder(), TriggerState.NORMAL);
this.mockMvc.perform(get("/actuator/quartz/triggers/samples/example")).andExpect(status().isOk())
.andDo(document("quartz/trigger-details-simple",
responseFields(commonCronDetails)
.and(fieldWithPath("simple").description("Simple trigger specific details."))
.andWithPrefix("simple.", simpleTriggerSummary)
.and(repeatCount("simple."), timesTriggered("simple."))));
}
@Test
void quartzTriggerCalendarInterval() throws Exception {
setupTriggerDetails(triggerThree.getTriggerBuilder(), TriggerState.NORMAL);
this.mockMvc.perform(get("/actuator/quartz/triggers/samples/example")).andExpect(status().isOk())
.andDo(document("quartz/trigger-details-calendar-interval", responseFields(commonCronDetails)
.and(fieldWithPath("calendarInterval")
.description("Calendar interval trigger specific details."))
.andWithPrefix("calendarInterval.", calendarIntervalTriggerSummary)
.and(timesTriggered("calendarInterval."),
fieldWithPath("calendarInterval.preserveHourOfDayAcrossDaylightSavings").description(
"Whether to fire the trigger at the same time of day, regardless of daylight "
+ "saving time transitions."),
fieldWithPath("calendarInterval.skipDayIfHourDoesNotExist").description(
"Whether to skip if the hour of the day does not exist on a given day."))));
}
@Test
void quartzTriggerDailyTimeInterval() throws Exception {
setupTriggerDetails(triggerFour.getTriggerBuilder(), TriggerState.PAUSED);
this.mockMvc.perform(get("/actuator/quartz/triggers/samples/example")).andExpect(status().isOk())
.andDo(document("quartz/trigger-details-daily-time-interval",
responseFields(commonCronDetails)
.and(fieldWithPath("dailyTimeInterval")
.description("Daily time interval trigger specific details."))
.andWithPrefix("dailyTimeInterval.", dailyTimeIntervalTriggerSummary)
.and(repeatCount("dailyTimeInterval."), timesTriggered("dailyTimeInterval."))));
}
@Test
public void quartzJob() throws Exception {
JobKey jobKey = jobOne.getKey();
given(this.scheduler.getJobDetail(jobKey)).willReturn(jobOne);
given(this.scheduler.getTriggersOfJob(jobKey)).willAnswer(invocation -> Arrays.asList(triggerOne, triggerTwo));
this.mockMvc.perform(get("/application/quartz/groupOne/jobOne")).andExpect(status().isOk()).andDo(document(
"quartz/job",
preprocessResponse(replacePattern(Pattern.compile("org.quartz.Job"), "com.example.MyJob")),
responseFields(fieldWithPath("jobGroup").description("Job group."),
fieldWithPath("jobName").description("Job name."),
fieldWithPath("description").description("Job description, if any."),
fieldWithPath("className").description("Job class."),
fieldWithPath("triggers.[].triggerGroup").description("Trigger group."),
fieldWithPath("triggers.[].triggerName").description("Trigger name."),
fieldWithPath("triggers.[].description").description("Trigger description, if any."),
fieldWithPath("triggers.[].calendarName").description("Trigger's calendar name, if any."),
fieldWithPath("triggers.[].startTime").description("Trigger's start time."),
fieldWithPath("triggers.[].endTime").description("Trigger's end time."),
fieldWithPath("triggers.[].nextFireTime").description("Trigger's next fire time."),
fieldWithPath("triggers.[].previousFireTime")
.description("Trigger's previous fire time, if any."),
fieldWithPath("triggers.[].finalFireTime").description("Trigger's final fire time, if any."))));
}
@Configuration
void quartzTriggerCustom() throws Exception {
Trigger trigger = mock(Trigger.class);
given(trigger.getKey()).willReturn(TriggerKey.triggerKey("example", "samples"));
given(trigger.getDescription()).willReturn("Example trigger.");
given(trigger.toString()).willReturn("com.example.CustomTrigger@fdsfsd");
given(trigger.getPriority()).willReturn(10);
given(trigger.getStartTime()).willReturn(fromUtc("2020-11-30T17:00:00Z"));
given(trigger.getEndTime()).willReturn(fromUtc("2020-12-30T03:00:00Z"));
given(trigger.getCalendarName()).willReturn("bankHolidays");
given(trigger.getPreviousFireTime()).willReturn(fromUtc("2020-12-04T03:00:00Z"));
given(trigger.getNextFireTime()).willReturn(fromUtc("2020-12-07T03:00:00Z"));
given(this.scheduler.getTriggerState(trigger.getKey())).willReturn(TriggerState.NORMAL);
mockTriggers(trigger);
this.mockMvc.perform(get("/actuator/quartz/triggers/samples/example")).andExpect(status().isOk())
.andDo(document("quartz/trigger-details-custom",
responseFields(commonCronDetails)
.and(fieldWithPath("custom").description("Custom trigger specific details."))
.andWithPrefix("custom.", customTriggerSummary)));
}
private <T extends Trigger> T setupTriggerDetails(TriggerBuilder<T> builder, TriggerState state)
throws SchedulerException {
T trigger = builder.withIdentity("example", "samples").withDescription("Example trigger")
.startAt(fromUtc("2020-11-30T17:00:00Z")).modifiedByCalendar("bankHolidays")
.endAt(fromUtc("2020-12-30T03:00:00Z")).build();
setPreviousNextFireTime(trigger, "2020-12-04T03:00:00Z", "2020-12-07T03:00:00Z");
given(this.scheduler.getTriggerState(trigger.getKey())).willReturn(state);
mockTriggers(trigger);
return trigger;
}
private static FieldDescriptor startTime(String prefix) {
return fieldWithPath(prefix + "startTime").description("Time at which the Trigger should take effect, if any.");
}
private static FieldDescriptor endTime(String prefix) {
return fieldWithPath(prefix + "endTime").description(
"Time at which the Trigger should quit repeating, regardless of any remaining repeats, if any.");
}
private static FieldDescriptor previousFireTime(String prefix) {
return fieldWithPath(prefix + "previousFireTime").optional().type(JsonFieldType.STRING)
.description("Last time the trigger fired, if any.");
}
private static FieldDescriptor nextFireTime(String prefix) {
return fieldWithPath(prefix + "nextFireTime").optional().type(JsonFieldType.STRING)
.description("Next time at which the Trigger is scheduled to fire, if any.");
}
private static FieldDescriptor priority(String prefix) {
return fieldWithPath(prefix + "priority")
.description("Priority to use if two triggers have the same scheduled fire time.");
}
private static FieldDescriptor repeatCount(String prefix) {
return fieldWithPath(prefix + "repeatCount")
.description("Number of times the trigger should repeat, or -1 to repeat indefinitely.");
}
private static FieldDescriptor timesTriggered(String prefix) {
return fieldWithPath(prefix + "timesTriggered").description("Number of times the trigger has already fired.");
}
private static List<FieldDescriptor> concat(List<FieldDescriptor> initial, List<FieldDescriptor> additionalFields) {
List<FieldDescriptor> result = new ArrayList<>(initial);
result.addAll(additionalFields);
return result;
}
private void mockJobs(JobDetail... jobs) throws SchedulerException {
MultiValueMap<String, JobKey> jobKeys = new LinkedMultiValueMap<>();
for (JobDetail jobDetail : jobs) {
JobKey key = jobDetail.getKey();
given(this.scheduler.getJobDetail(key)).willReturn(jobDetail);
jobKeys.add(key.getGroup(), key);
}
given(this.scheduler.getJobGroupNames()).willReturn(new ArrayList<>(jobKeys.keySet()));
for (Entry<String, List<JobKey>> entry : jobKeys.entrySet()) {
given(this.scheduler.getJobKeys(GroupMatcher.jobGroupEquals(entry.getKey())))
.willReturn(new LinkedHashSet<>(entry.getValue()));
}
}
private void mockTriggers(Trigger... triggers) throws SchedulerException {
MultiValueMap<String, TriggerKey> triggerKeys = new LinkedMultiValueMap<>();
for (Trigger trigger : triggers) {
TriggerKey key = trigger.getKey();
given(this.scheduler.getTrigger(key)).willReturn(trigger);
triggerKeys.add(key.getGroup(), key);
}
given(this.scheduler.getTriggerGroupNames()).willReturn(new ArrayList<>(triggerKeys.keySet()));
for (Entry<String, List<TriggerKey>> entry : triggerKeys.entrySet()) {
given(this.scheduler.getTriggerKeys(GroupMatcher.triggerGroupEquals(entry.getKey())))
.willReturn(new LinkedHashSet<>(entry.getValue()));
}
}
private <T extends Trigger> T setPreviousNextFireTime(T trigger, String previousFireTime, String nextFireTime) {
OperableTrigger operableTrigger = (OperableTrigger) trigger;
if (previousFireTime != null) {
operableTrigger.setPreviousFireTime(fromUtc(previousFireTime));
}
if (nextFireTime != null) {
operableTrigger.setNextFireTime(fromUtc(nextFireTime));
}
return trigger;
}
private static Date fromUtc(String utcTime) {
return Date.from(Instant.parse(utcTime));
}
@Configuration(proxyBeanMethods = false)
@Import(BaseDocumentationConfiguration.class)
static class TestConfiguration {
@Bean
public QuartzEndpoint endpoint(Scheduler scheduler) {
QuartzEndpoint endpoint(Scheduler scheduler) {
return new QuartzEndpoint(scheduler);
}
@Bean
QuartzEndpointWebExtension endpointWebExtension(QuartzEndpoint endpoint) {
return new QuartzEndpointWebExtension(endpoint);
}
}
}

@ -1,11 +1,11 @@
/*
* Copyright 2012-2017 the original author or authors.
* Copyright 2012-2021 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
@ -16,7 +16,7 @@
package org.springframework.boot.actuate.autoconfigure.quartz;
import org.junit.Test;
import org.junit.jupiter.api.Test;
import org.quartz.Scheduler;
import org.springframework.boot.actuate.quartz.QuartzEndpoint;
@ -32,30 +32,59 @@ import static org.mockito.Mockito.mock;
* Tests for {@link QuartzEndpointAutoConfiguration}.
*
* @author Vedran Pavic
* @author Stephane Nicoll
*/
public class QuartzEndpointAutoConfigurationTests {
class QuartzEndpointAutoConfigurationTests {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(QuartzEndpointAutoConfiguration.class))
.withUserConfiguration(QuartzConfiguration.class);
.withConfiguration(AutoConfigurations.of(QuartzEndpointAutoConfiguration.class));
@Test
public void runShouldHaveEndpointBean() {
this.contextRunner.run((context) -> assertThat(context).hasSingleBean(QuartzEndpoint.class));
void endpointIsAutoConfigured() {
this.contextRunner.withBean(Scheduler.class, () -> mock(Scheduler.class))
.withPropertyValues("management.endpoints.web.exposure.include=quartz")
.run((context) -> assertThat(context).hasSingleBean(QuartzEndpoint.class));
}
@Test
public void runWhenEnabledPropertyIsFalseShouldNotHaveEndpointBean() throws Exception {
this.contextRunner.withPropertyValues("management.endpoint.quartz.enabled:false")
void endpointIsNotAutoConfiguredIfSchedulerIsNotAvailable() {
this.contextRunner.withPropertyValues("management.endpoints.web.exposure.include=quartz")
.run((context) -> assertThat(context).doesNotHaveBean(QuartzEndpoint.class));
}
@Configuration
static class QuartzConfiguration {
@Test
void endpointNotAutoConfiguredWhenNotExposed() {
this.contextRunner.withBean(Scheduler.class, () -> mock(Scheduler.class))
.run((context) -> assertThat(context).doesNotHaveBean(QuartzEndpoint.class));
}
@Test
void endpointCanBeDisabled() {
this.contextRunner.withBean(Scheduler.class, () -> mock(Scheduler.class))
.withPropertyValues("management.endpoint.quartz.enabled:false")
.run((context) -> assertThat(context).doesNotHaveBean(QuartzEndpoint.class));
}
@Test
void endpointBacksOffWhenUserProvidedEndpointIsPresent() {
this.contextRunner.withUserConfiguration(CustomEndpointConfiguration.class)
.run((context) -> assertThat(context).hasSingleBean(QuartzEndpoint.class).hasBean("customEndpoint"));
}
@Configuration(proxyBeanMethods = false)
static class CustomEndpointConfiguration {
@Bean
public Scheduler scheduler() {
return mock(Scheduler.class);
CustomEndpoint customEndpoint() {
return new CustomEndpoint();
}
}
private static final class CustomEndpoint extends QuartzEndpoint {
private CustomEndpoint() {
super(mock(Scheduler.class));
}
}

@ -1,11 +1,11 @@
/*
* Copyright 2012-2017 the original author or authors.
* Copyright 2012-2021 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
@ -16,99 +16,421 @@
package org.springframework.boot.actuate.quartz;
import java.time.Duration;
import java.time.LocalTime;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalUnit;
import java.util.ArrayList;
import java.util.Date;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.quartz.CalendarIntervalTrigger;
import org.quartz.CronTrigger;
import org.quartz.DailyTimeIntervalTrigger;
import org.quartz.DateBuilder.IntervalUnit;
import org.quartz.Job;
import org.quartz.JobDataMap;
import org.quartz.JobDetail;
import org.quartz.JobKey;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.SimpleTrigger;
import org.quartz.TimeOfDay;
import org.quartz.Trigger;
import org.quartz.Trigger.TriggerState;
import org.quartz.TriggerKey;
import org.quartz.impl.matchers.GroupMatcher;
import org.springframework.boot.actuate.endpoint.Sanitizer;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.annotation.Selector;
import org.springframework.util.Assert;
/**
* {@link Endpoint} to expose Quartz Scheduler info.
* {@link Endpoint} to expose Quartz Scheduler jobs and triggers.
*
* @author Vedran Pavic
* @since 2.0.0
* @author Stephane Nicoll
* @since 2.5.0
*/
@Endpoint(id = "quartz")
public class QuartzEndpoint {
private static final Comparator<Trigger> TRIGGER_COMPARATOR = Comparator
.comparing(Trigger::getNextFireTime, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(Comparator.comparingInt(Trigger::getPriority).reversed());
private final Scheduler scheduler;
public QuartzEndpoint(Scheduler scheduler) {
private final Sanitizer sanitizer;
/**
* Create an instance for the specified {@link Scheduler} and {@link Sanitizer}.
* @param scheduler the scheduler to use to retrieve jobs and triggers details
* @param sanitizer the sanitizer to use to sanitize data maps
*/
public QuartzEndpoint(Scheduler scheduler, Sanitizer sanitizer) {
Assert.notNull(scheduler, "Scheduler must not be null");
Assert.notNull(sanitizer, "Sanitizer must not be null");
this.scheduler = scheduler;
this.sanitizer = sanitizer;
}
/**
* Create an instance for the specified {@link Scheduler} using a default
* {@link Sanitizer}.
* @param scheduler the scheduler to use to retrieve jobs and triggers details
*/
public QuartzEndpoint(Scheduler scheduler) {
this(scheduler, new Sanitizer());
}
/**
* Return the available job and trigger group names.
* @return a report of the available group names
* @throws SchedulerException if retrieving the information from the scheduler failed
*/
@ReadOperation
public Map<String, Object> quartzReport() {
public QuartzReport quartzReport() throws SchedulerException {
return new QuartzReport(new GroupNames(this.scheduler.getJobGroupNames()),
new GroupNames(this.scheduler.getTriggerGroupNames()));
}
/**
* Return the available job names, identified by group name.
* @return the available job names
* @throws SchedulerException if retrieving the information from the scheduler failed
*/
public QuartzGroups quartzJobGroups() throws SchedulerException {
Map<String, Object> result = new LinkedHashMap<>();
try {
for (String groupName : this.scheduler.getJobGroupNames()) {
List<String> jobs = this.scheduler.getJobKeys(GroupMatcher.jobGroupEquals(groupName)).stream()
.map(JobKey::getName).collect(Collectors.toList());
result.put(groupName, jobs);
.map((key) -> key.getName()).collect(Collectors.toList());
result.put(groupName, Collections.singletonMap("jobs", jobs));
}
return new QuartzGroups(result);
}
catch (SchedulerException ignored) {
/**
* Return the available trigger names, identified by group name.
* @return the available trigger names
* @throws SchedulerException if retrieving the information from the scheduler failed
*/
public QuartzGroups quartzTriggerGroups() throws SchedulerException {
Map<String, Object> result = new LinkedHashMap<>();
Set<String> pausedTriggerGroups = this.scheduler.getPausedTriggerGroups();
for (String groupName : this.scheduler.getTriggerGroupNames()) {
Map<String, Object> groupDetails = new LinkedHashMap<>();
groupDetails.put("paused", pausedTriggerGroups.contains(groupName));
groupDetails.put("triggers", this.scheduler.getTriggerKeys(GroupMatcher.triggerGroupEquals(groupName))
.stream().map((key) -> key.getName()).collect(Collectors.toList()));
result.put(groupName, groupDetails);
}
return result;
return new QuartzGroups(result);
}
@ReadOperation
public QuartzJob quartzJob(@Selector String groupName, @Selector String jobName) {
try {
/**
* Return a summary of the jobs group with the specified name or {@code null} if no
* such group exists.
* @param group the name of a jobs group
* @return a summary of the jobs in the given {@code group}
* @throws SchedulerException if retrieving the information from the scheduler failed
*/
public QuartzJobGroupSummary quartzJobGroupSummary(String group) throws SchedulerException {
List<JobDetail> jobs = findJobsByGroup(group);
if (jobs.isEmpty() && !this.scheduler.getJobGroupNames().contains(group)) {
return null;
}
Map<String, QuartzJobSummary> result = new LinkedHashMap<>();
for (JobDetail job : jobs) {
result.put(job.getKey().getName(), QuartzJobSummary.of(job));
}
return new QuartzJobGroupSummary(group, result);
}
private List<JobDetail> findJobsByGroup(String group) throws SchedulerException {
List<JobDetail> jobs = new ArrayList<>();
Set<JobKey> jobKeys = this.scheduler.getJobKeys(GroupMatcher.jobGroupEquals(group));
for (JobKey jobKey : jobKeys) {
jobs.add(this.scheduler.getJobDetail(jobKey));
}
return jobs;
}
/**
* Return a summary of the triggers group with the specified name or {@code null} if
* no such group exists.
* @param group the name of a triggers group
* @return a summary of the triggers in the given {@code group}
* @throws SchedulerException if retrieving the information from the scheduler failed
*/
public QuartzTriggerGroupSummary quartzTriggerGroupSummary(String group) throws SchedulerException {
List<Trigger> triggers = findTriggersByGroup(group);
if (triggers.isEmpty() && !this.scheduler.getTriggerGroupNames().contains(group)) {
return null;
}
Map<TriggerType, Map<String, Object>> result = new LinkedHashMap<>();
triggers.forEach((trigger) -> {
TriggerDescription triggerDescription = TriggerDescription.of(trigger);
Map<String, Object> triggerTypes = result.computeIfAbsent(triggerDescription.getType(),
(key) -> new LinkedHashMap<>());
triggerTypes.put(trigger.getKey().getName(), triggerDescription.buildSummary(true));
});
boolean paused = this.scheduler.getPausedTriggerGroups().contains(group);
return new QuartzTriggerGroupSummary(group, paused, result);
}
private List<Trigger> findTriggersByGroup(String group) throws SchedulerException {
List<Trigger> triggers = new ArrayList<>();
Set<TriggerKey> triggerKeys = this.scheduler.getTriggerKeys(GroupMatcher.triggerGroupEquals(group));
for (TriggerKey triggerKey : triggerKeys) {
triggers.add(this.scheduler.getTrigger(triggerKey));
}
return triggers;
}
/**
* Return the {@link QuartzJobDetails details of the job} identified with the given
* group name and job name.
* @param groupName the name of the group
* @param jobName the name of the job
* @return the details of the job or {@code null} if such job does not exist
* @throws SchedulerException if retrieving the information from the scheduler failed
*/
public QuartzJobDetails quartzJob(String groupName, String jobName) throws SchedulerException {
JobKey jobKey = JobKey.jobKey(jobName, groupName);
JobDetail jobDetail = this.scheduler.getJobDetail(jobKey);
if (jobDetail != null) {
List<? extends Trigger> triggers = this.scheduler.getTriggersOfJob(jobKey);
return new QuartzJob(jobDetail, triggers);
return new QuartzJobDetails(jobDetail.getKey().getGroup(), jobDetail.getKey().getName(),
jobDetail.getDescription(), jobDetail.getJobClass().getName(), jobDetail.isDurable(),
jobDetail.requestsRecovery(), sanitizeJobDataMap(jobDetail.getJobDataMap()),
extractTriggersSummary(triggers));
}
catch (SchedulerException e) {
return null;
}
private static List<Map<String, Object>> extractTriggersSummary(List<? extends Trigger> triggers) {
List<Trigger> triggersToSort = new ArrayList<>(triggers);
triggersToSort.sort(TRIGGER_COMPARATOR);
List<Map<String, Object>> result = new ArrayList<>();
triggersToSort.forEach((trigger) -> {
Map<String, Object> triggerSummary = new LinkedHashMap<>();
triggerSummary.put("group", trigger.getKey().getGroup());
triggerSummary.put("name", trigger.getKey().getName());
triggerSummary.putAll(TriggerDescription.of(trigger).buildSummary(false));
result.add(triggerSummary);
});
return result;
}
/**
* Details of a {@link Job Quartz Job}.
* Return the details of the trigger identified by the given group name and trigger
* name.
* @param groupName the name of the group
* @param triggerName the name of the trigger
* @return the details of the trigger or {@code null} if such trigger does not exist
* @throws SchedulerException if retrieving the information from the scheduler failed
*/
public static final class QuartzJob {
public Map<String, Object> quartzTrigger(String groupName, String triggerName) throws SchedulerException {
TriggerKey triggerKey = TriggerKey.triggerKey(triggerName, groupName);
Trigger trigger = this.scheduler.getTrigger(triggerKey);
return (trigger != null) ? TriggerDescription.of(trigger).buildDetails(
this.scheduler.getTriggerState(triggerKey), sanitizeJobDataMap(trigger.getJobDataMap())) : null;
}
private final String jobGroup;
private static Duration getIntervalDuration(long amount, IntervalUnit unit) {
return temporalUnit(unit).getDuration().multipliedBy(amount);
}
private final String jobName;
private static LocalTime getLocalTime(TimeOfDay timeOfDay) {
return (timeOfDay != null) ? LocalTime.of(timeOfDay.getHour(), timeOfDay.getMinute(), timeOfDay.getSecond())
: null;
}
private Map<String, Object> sanitizeJobDataMap(JobDataMap dataMap) {
if (dataMap != null) {
Map<String, Object> map = new LinkedHashMap<>(dataMap.getWrappedMap());
map.replaceAll(this.sanitizer::sanitize);
return map;
}
return null;
}
private static TemporalUnit temporalUnit(IntervalUnit unit) {
switch (unit) {
case DAY:
return ChronoUnit.DAYS;
case HOUR:
return ChronoUnit.HOURS;
case MINUTE:
return ChronoUnit.MINUTES;
case MONTH:
return ChronoUnit.MONTHS;
case SECOND:
return ChronoUnit.SECONDS;
case MILLISECOND:
return ChronoUnit.MILLIS;
case WEEK:
return ChronoUnit.WEEKS;
case YEAR:
return ChronoUnit.YEARS;
default:
throw new IllegalArgumentException("Unknown IntervalUnit");
}
}
/**
* A report of available job and trigger group names, primarily intended for
* serialization to JSON.
*/
public static final class QuartzReport {
private final GroupNames jobs;
private final GroupNames triggers;
QuartzReport(GroupNames jobs, GroupNames triggers) {
this.jobs = jobs;
this.triggers = triggers;
}
public GroupNames getJobs() {
return this.jobs;
}
public GroupNames getTriggers() {
return this.triggers;
}
}
/**
* A set of group names, primarily intended for serialization to JSON.
*/
public static class GroupNames {
private final Set<String> groups;
public GroupNames(List<String> groups) {
this.groups = new LinkedHashSet<>(groups);
}
public Set<String> getGroups() {
return this.groups;
}
}
/**
* A summary for each group identified by name, primarily intended for serialization
* to JSON.
*/
public static class QuartzGroups {
private final Map<String, Object> groups;
public QuartzGroups(Map<String, Object> groups) {
this.groups = groups;
}
public Map<String, Object> getGroups() {
return this.groups;
}
}
/**
* A summary report of the {@link JobDetail jobs} in a given group.
*/
public static final class QuartzJobGroupSummary {
private final String group;
private final Map<String, QuartzJobSummary> jobs;
private QuartzJobGroupSummary(String group, Map<String, QuartzJobSummary> jobs) {
this.group = group;
this.jobs = jobs;
}
public String getGroup() {
return this.group;
}
public Map<String, QuartzJobSummary> getJobs() {
return this.jobs;
}
}
/**
* Details of a {@link Job Quartz Job}, primarily intended for serialization to JSON.
*/
public static final class QuartzJobSummary {
private final String className;
private QuartzJobSummary(JobDetail job) {
this.className = job.getJobClass().getName();
}
private static QuartzJobSummary of(JobDetail job) {
return new QuartzJobSummary(job);
}
public String getClassName() {
return this.className;
}
}
/**
* Details of a {@link Job Quartz Job}, primarily intended for serialization to JSON.
*/
public static final class QuartzJobDetails {
private final String group;
private final String name;
private final String description;
private final String className;
private final List<QuartzTrigger> triggers = new ArrayList<>();
private final boolean durable;
private final boolean requestRecovery;
QuartzJob(JobDetail jobDetail, List<? extends Trigger> triggers) {
this.jobGroup = jobDetail.getKey().getGroup();
this.jobName = jobDetail.getKey().getName();
this.description = jobDetail.getDescription();
this.className = jobDetail.getJobClass().getName();
triggers.forEach(trigger -> this.triggers.add(new QuartzTrigger(trigger)));
private final Map<String, Object> data;
private final List<Map<String, Object>> triggers;
QuartzJobDetails(String group, String name, String description, String className, boolean durable,
boolean requestRecovery, Map<String, Object> data, List<Map<String, Object>> triggers) {
this.group = group;
this.name = name;
this.description = description;
this.className = className;
this.durable = durable;
this.requestRecovery = requestRecovery;
this.data = data;
this.triggers = triggers;
}
public String getJobGroup() {
return this.jobGroup;
public String getGroup() {
return this.group;
}
public String getJobName() {
return this.jobName;
public String getName() {
return this.name;
}
public String getDescription() {
@ -119,81 +441,360 @@ public class QuartzEndpoint {
return this.className;
}
public List<QuartzTrigger> getTriggers() {
public boolean isDurable() {
return this.durable;
}
public boolean isRequestRecovery() {
return this.requestRecovery;
}
public Map<String, Object> getData() {
return this.data;
}
public List<Map<String, Object>> getTriggers() {
return this.triggers;
}
}
/**
* Details of a {@link Trigger Quartz Trigger}.
* A summary report of the {@link Trigger triggers} in a given group.
*/
public static final class QuartzTrigger {
public static final class QuartzTriggerGroupSummary {
private final String triggerGroup;
private final String group;
private final String triggerName;
private final boolean paused;
private final String description;
private final Triggers triggers;
private QuartzTriggerGroupSummary(String group, boolean paused,
Map<TriggerType, Map<String, Object>> descriptionsByType) {
this.group = group;
this.paused = paused;
this.triggers = new Triggers(descriptionsByType);
}
private final String calendarName;
public String getGroup() {
return this.group;
}
private final Date startTime;
public boolean isPaused() {
return this.paused;
}
private final Date endTime;
public Triggers getTriggers() {
return this.triggers;
}
private final Date previousFireTime;
public static final class Triggers {
private final Date nextFireTime;
private final Map<String, Object> cron;
private final Date finalFireTime;
private final Map<String, Object> simple;
QuartzTrigger(Trigger trigger) {
this.triggerGroup = trigger.getKey().getGroup();
this.triggerName = trigger.getKey().getName();
this.description = trigger.getDescription();
this.calendarName = trigger.getCalendarName();
this.startTime = trigger.getStartTime();
this.endTime = trigger.getEndTime();
this.previousFireTime = trigger.getPreviousFireTime();
this.nextFireTime = trigger.getNextFireTime();
this.finalFireTime = trigger.getFinalFireTime();
private final Map<String, Object> dailyTimeInterval;
private final Map<String, Object> calendarInterval;
private final Map<String, Object> custom;
private Triggers(Map<TriggerType, Map<String, Object>> descriptionsByType) {
this.cron = descriptionsByType.getOrDefault(TriggerType.CRON, Collections.emptyMap());
this.dailyTimeInterval = descriptionsByType.getOrDefault(TriggerType.DAILY_INTERVAL,
Collections.emptyMap());
this.calendarInterval = descriptionsByType.getOrDefault(TriggerType.CALENDAR_INTERVAL,
Collections.emptyMap());
this.simple = descriptionsByType.getOrDefault(TriggerType.SIMPLE, Collections.emptyMap());
this.custom = descriptionsByType.getOrDefault(TriggerType.CUSTOM_TRIGGER, Collections.emptyMap());
}
public String getTriggerGroup() {
return this.triggerGroup;
public Map<String, Object> getCron() {
return this.cron;
}
public String getTriggerName() {
return this.triggerName;
public Map<String, Object> getSimple() {
return this.simple;
}
public String getDescription() {
return this.description;
public Map<String, Object> getDailyTimeInterval() {
return this.dailyTimeInterval;
}
public Map<String, Object> getCalendarInterval() {
return this.calendarInterval;
}
public Map<String, Object> getCustom() {
return this.custom;
}
}
}
private enum TriggerType {
CRON("cron"),
CUSTOM_TRIGGER("custom"),
CALENDAR_INTERVAL("calendarInterval"),
DAILY_INTERVAL("dailyTimeInterval"),
SIMPLE("simple");
private final String id;
TriggerType(String id) {
this.id = id;
}
public String getId() {
return this.id;
}
public String getCalendarName() {
return this.calendarName;
}
public Date getStartTime() {
return this.startTime;
/**
* Base class for descriptions of a {@link Trigger}.
*/
public abstract static class TriggerDescription {
private static final Map<Class<? extends Trigger>, Function<Trigger, TriggerDescription>> DESCRIBERS = new LinkedHashMap<>();
static {
DESCRIBERS.put(CronTrigger.class, (trigger) -> new CronTriggerDescription((CronTrigger) trigger));
DESCRIBERS.put(SimpleTrigger.class, (trigger) -> new SimpleTriggerDescription((SimpleTrigger) trigger));
DESCRIBERS.put(DailyTimeIntervalTrigger.class,
(trigger) -> new DailyTimeIntervalTriggerDescription((DailyTimeIntervalTrigger) trigger));
DESCRIBERS.put(CalendarIntervalTrigger.class,
(trigger) -> new CalendarIntervalTriggerDescription((CalendarIntervalTrigger) trigger));
}
public Date getEndTime() {
return this.endTime;
private final Trigger trigger;
private final TriggerType type;
private static TriggerDescription of(Trigger trigger) {
return DESCRIBERS.entrySet().stream().filter((entry) -> entry.getKey().isInstance(trigger))
.map((entry) -> entry.getValue().apply(trigger)).findFirst()
.orElse(new CustomTriggerDescription(trigger));
}
public Date getPreviousFireTime() {
return this.previousFireTime;
protected TriggerDescription(Trigger trigger, TriggerType type) {
this.trigger = trigger;
this.type = type;
}
/**
* Build the summary of the trigger.
* @param addTriggerSpecificSummary whether to add trigger-implementation specific
* summary.
* @return basic properties of the trigger
*/
public Map<String, Object> buildSummary(boolean addTriggerSpecificSummary) {
Map<String, Object> summary = new LinkedHashMap<>();
putIfNoNull(summary, "previousFireTime", this.trigger.getPreviousFireTime());
putIfNoNull(summary, "nextFireTime", this.trigger.getNextFireTime());
summary.put("priority", this.trigger.getPriority());
if (addTriggerSpecificSummary) {
appendSummary(summary);
}
return summary;
}
/**
* Append trigger-implementation specific summary items to the specified
* {@code content}.
* @param content the summary of the trigger
*/
protected abstract void appendSummary(Map<String, Object> content);
/**
* Build the full details of the trigger.
* @param triggerState the current state of the trigger
* @param sanitizedDataMap a sanitized data map or {@code null}
* @return all properties of the trigger
*/
public Map<String, Object> buildDetails(TriggerState triggerState, Map<String, Object> sanitizedDataMap) {
Map<String, Object> details = new LinkedHashMap<>();
details.put("group", this.trigger.getKey().getGroup());
details.put("name", this.trigger.getKey().getName());
putIfNoNull(details, "description", this.trigger.getDescription());
details.put("state", triggerState);
details.put("type", getType().getId());
putIfNoNull(details, "calendarName", this.trigger.getCalendarName());
putIfNoNull(details, "startTime", this.trigger.getStartTime());
putIfNoNull(details, "endTime", this.trigger.getEndTime());
putIfNoNull(details, "previousFireTime", this.trigger.getPreviousFireTime());
putIfNoNull(details, "nextFireTime", this.trigger.getNextFireTime());
putIfNoNull(details, "priority", this.trigger.getPriority());
putIfNoNull(details, "finalFireTime", this.trigger.getFinalFireTime());
putIfNoNull(details, "data", sanitizedDataMap);
Map<String, Object> typeDetails = new LinkedHashMap<>();
appendDetails(typeDetails);
details.put(getType().getId(), typeDetails);
return details;
}
/**
* Append trigger-implementation specific details to the specified
* {@code content}.
* @param content the details of the trigger
*/
protected abstract void appendDetails(Map<String, Object> content);
protected void putIfNoNull(Map<String, Object> content, String key, Object value) {
if (value != null) {
content.put(key, value);
}
}
protected Trigger getTrigger() {
return this.trigger;
}
protected TriggerType getType() {
return this.type;
}
}
/**
* A description of a {@link CronTrigger}.
*/
public static final class CronTriggerDescription extends TriggerDescription {
private final CronTrigger trigger;
public CronTriggerDescription(CronTrigger trigger) {
super(trigger, TriggerType.CRON);
this.trigger = trigger;
}
@Override
protected void appendSummary(Map<String, Object> content) {
content.put("expression", this.trigger.getCronExpression());
putIfNoNull(content, "timeZone", this.trigger.getTimeZone());
}
@Override
protected void appendDetails(Map<String, Object> content) {
appendSummary(content);
}
}
/**
* A description of a {@link SimpleTrigger}.
*/
public static final class SimpleTriggerDescription extends TriggerDescription {
private final SimpleTrigger trigger;
public SimpleTriggerDescription(SimpleTrigger trigger) {
super(trigger, TriggerType.SIMPLE);
this.trigger = trigger;
}
@Override
protected void appendSummary(Map<String, Object> content) {
content.put("interval", this.trigger.getRepeatInterval());
}
@Override
protected void appendDetails(Map<String, Object> content) {
appendSummary(content);
content.put("repeatCount", this.trigger.getRepeatCount());
content.put("timesTriggered", this.trigger.getTimesTriggered());
}
}
/**
* A description of a {@link DailyTimeIntervalTrigger}.
*/
public static final class DailyTimeIntervalTriggerDescription extends TriggerDescription {
private final DailyTimeIntervalTrigger trigger;
public DailyTimeIntervalTriggerDescription(DailyTimeIntervalTrigger trigger) {
super(trigger, TriggerType.DAILY_INTERVAL);
this.trigger = trigger;
}
@Override
protected void appendSummary(Map<String, Object> content) {
content.put("interval",
getIntervalDuration(this.trigger.getRepeatInterval(), this.trigger.getRepeatIntervalUnit())
.toMillis());
putIfNoNull(content, "daysOfWeek", this.trigger.getDaysOfWeek());
putIfNoNull(content, "startTimeOfDay", getLocalTime(this.trigger.getStartTimeOfDay()));
putIfNoNull(content, "endTimeOfDay", getLocalTime(this.trigger.getEndTimeOfDay()));
}
@Override
protected void appendDetails(Map<String, Object> content) {
appendSummary(content);
content.put("repeatCount", this.trigger.getRepeatCount());
content.put("timesTriggered", this.trigger.getTimesTriggered());
}
}
/**
* A description of a {@link CalendarIntervalTrigger}.
*/
public static final class CalendarIntervalTriggerDescription extends TriggerDescription {
private final CalendarIntervalTrigger trigger;
public CalendarIntervalTriggerDescription(CalendarIntervalTrigger trigger) {
super(trigger, TriggerType.CALENDAR_INTERVAL);
this.trigger = trigger;
}
@Override
protected void appendSummary(Map<String, Object> content) {
content.put("interval",
getIntervalDuration(this.trigger.getRepeatInterval(), this.trigger.getRepeatIntervalUnit())
.toMillis());
putIfNoNull(content, "timeZone", this.trigger.getTimeZone());
}
@Override
protected void appendDetails(Map<String, Object> content) {
appendSummary(content);
content.put("timesTriggered", this.trigger.getTimesTriggered());
content.put("preserveHourOfDayAcrossDaylightSavings",
this.trigger.isPreserveHourOfDayAcrossDaylightSavings());
content.put("skipDayIfHourDoesNotExist", this.trigger.isSkipDayIfHourDoesNotExist());
}
}
/**
* A description of a custom {@link Trigger}.
*/
public static final class CustomTriggerDescription extends TriggerDescription {
public CustomTriggerDescription(Trigger trigger) {
super(trigger, TriggerType.CUSTOM_TRIGGER);
}
public Date getNextFireTime() {
return this.nextFireTime;
@Override
protected void appendSummary(Map<String, Object> content) {
content.put("trigger", getTrigger().toString());
}
public Date getFinalFireTime() {
return this.finalFireTime;
@Override
protected void appendDetails(Map<String, Object> content) {
appendSummary(content);
}
}

@ -0,0 +1,87 @@
/*
* Copyright 2012-2021 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.actuate.quartz;
import org.quartz.SchedulerException;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.annotation.Selector;
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExtension;
import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzGroups;
/**
* {@link EndpointWebExtension @EndpointWebExtension} for the {@link QuartzEndpoint}.
*
* @author Stephane Nicoll
* @since 2.5.0
*/
@EndpointWebExtension(endpoint = QuartzEndpoint.class)
public class QuartzEndpointWebExtension {
private final QuartzEndpoint delegate;
public QuartzEndpointWebExtension(QuartzEndpoint delegate) {
this.delegate = delegate;
}
@ReadOperation
public WebEndpointResponse<QuartzGroups> quartzJobOrTriggerGroups(@Selector String jobsOrTriggers)
throws SchedulerException {
return handle(jobsOrTriggers, this.delegate::quartzJobGroups, this.delegate::quartzTriggerGroups);
}
@ReadOperation
public WebEndpointResponse<Object> quartzJobOrTriggerGroup(@Selector String jobsOrTriggers, @Selector String group)
throws SchedulerException {
return handle(jobsOrTriggers, () -> this.delegate.quartzJobGroupSummary(group),
() -> this.delegate.quartzTriggerGroupSummary(group));
}
@ReadOperation
public WebEndpointResponse<Object> quartzJobOrTrigger(@Selector String jobsOrTriggers, @Selector String group,
@Selector String name) throws SchedulerException {
return handle(jobsOrTriggers, () -> this.delegate.quartzJob(group, name),
() -> this.delegate.quartzTrigger(group, name));
}
private <T> WebEndpointResponse<T> handle(String jobsOrTriggers, ResponseSupplier<T> jobAction,
ResponseSupplier<T> triggerAction) throws SchedulerException {
if ("jobs".equals(jobsOrTriggers)) {
return handleNull(jobAction.get());
}
if ("triggers".equals(jobsOrTriggers)) {
return handleNull(triggerAction.get());
}
return new WebEndpointResponse<>(WebEndpointResponse.STATUS_BAD_REQUEST);
}
private <T> WebEndpointResponse<T> handleNull(T value) {
if (value != null) {
return new WebEndpointResponse<>(value);
}
return new WebEndpointResponse<>(WebEndpointResponse.STATUS_NOT_FOUND);
}
@FunctionalInterface
private interface ResponseSupplier<T> {
T get() throws SchedulerException;
}
}

@ -1,11 +1,11 @@
/*
* Copyright 2012-2017 the original author or authors.
* Copyright 2012-2021 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,

@ -1,11 +1,11 @@
/*
* Copyright 2012-2017 the original author or authors.
* Copyright 2012-2021 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
@ -16,64 +16,695 @@
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.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
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;
import java.util.stream.Stream;
import org.junit.Test;
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.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.quartz.CalendarIntervalScheduleBuilder;
import org.quartz.CalendarIntervalTrigger;
import org.quartz.CronScheduleBuilder;
import org.quartz.CronTrigger;
import org.quartz.DailyTimeIntervalScheduleBuilder;
import org.quartz.DailyTimeIntervalTrigger;
import org.quartz.DateBuilder.IntervalUnit;
import org.quartz.Job;
import org.quartz.JobBuilder;
import org.quartz.JobDetail;
import org.quartz.JobKey;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.SimpleScheduleBuilder;
import org.quartz.SimpleTrigger;
import org.quartz.TimeOfDay;
import org.quartz.Trigger;
import org.quartz.Trigger.TriggerState;
import org.quartz.TriggerBuilder;
import org.quartz.TriggerKey;
import org.quartz.impl.matchers.GroupMatcher;
import org.quartz.spi.OperableTrigger;
import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzJob;
import org.springframework.boot.actuate.endpoint.Sanitizer;
import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzJobDetails;
import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzJobGroupSummary;
import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzJobSummary;
import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzReport;
import org.springframework.boot.actuate.quartz.QuartzEndpoint.QuartzTriggerGroupSummary;
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.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
/**
* Tests for {@link QuartzEndpoint}.
*
* @author Vedran Pavic
* @author Stephane Nicoll
*/
public class QuartzEndpointTests {
class QuartzEndpointTests {
private final Scheduler scheduler = mock(Scheduler.class);
private static final JobDetail jobOne = JobBuilder.newJob(Job.class).withIdentity("jobOne").build();
private final QuartzEndpoint endpoint = new QuartzEndpoint(this.scheduler);
private static final JobDetail jobTwo = JobBuilder.newJob(DelegatingJob.class).withIdentity("jobTwo").build();
private final JobDetail jobDetail = JobBuilder.newJob(Job.class).withIdentity("testJob").build();
private static final JobDetail jobThree = JobBuilder.newJob(Job.class).withIdentity("jobThree", "samples").build();
private final Trigger trigger = TriggerBuilder.newTrigger().forJob(this.jobDetail).withIdentity("testTrigger")
private static final Trigger triggerOne = TriggerBuilder.newTrigger().forJob(jobOne).withIdentity("triggerOne")
.build();
private static final Trigger triggerTwo = TriggerBuilder.newTrigger().forJob(jobOne).withIdentity("triggerTwo")
.build();
private static final Trigger triggerThree = TriggerBuilder.newTrigger().forJob(jobThree)
.withIdentity("triggerThree", "samples").build();
private final Scheduler scheduler;
private final QuartzEndpoint endpoint;
QuartzEndpointTests() {
this.scheduler = mock(Scheduler.class);
this.endpoint = new QuartzEndpoint(this.scheduler);
}
@Test
void quartzReport() throws SchedulerException {
given(this.scheduler.getJobGroupNames()).willReturn(Arrays.asList("jobSamples", "DEFAULT"));
given(this.scheduler.getTriggerGroupNames()).willReturn(Collections.singletonList("triggerSamples"));
QuartzReport quartzReport = this.endpoint.quartzReport();
assertThat(quartzReport.getJobs().getGroups()).containsOnly("jobSamples", "DEFAULT");
assertThat(quartzReport.getTriggers().getGroups()).containsOnly("triggerSamples");
verify(this.scheduler).getJobGroupNames();
verify(this.scheduler).getTriggerGroupNames();
verifyNoMoreInteractions(this.scheduler);
}
@Test
void quartzReportWithNoJob() throws SchedulerException {
given(this.scheduler.getJobGroupNames()).willReturn(Collections.emptyList());
given(this.scheduler.getTriggerGroupNames()).willReturn(Arrays.asList("triggerSamples", "DEFAULT"));
QuartzReport quartzReport = this.endpoint.quartzReport();
assertThat(quartzReport.getJobs().getGroups()).isEmpty();
assertThat(quartzReport.getTriggers().getGroups()).containsOnly("triggerSamples", "DEFAULT");
}
@Test
void quartzReportWithNoTrigger() throws SchedulerException {
given(this.scheduler.getJobGroupNames()).willReturn(Collections.singletonList("jobSamples"));
given(this.scheduler.getTriggerGroupNames()).willReturn(Collections.emptyList());
QuartzReport quartzReport = this.endpoint.quartzReport();
assertThat(quartzReport.getJobs().getGroups()).containsOnly("jobSamples");
assertThat(quartzReport.getTriggers().getGroups()).isEmpty();
}
@Test
void quartzJobGroupsWithExistingGroups() throws SchedulerException {
mockJobs(jobOne, jobTwo, jobThree);
Map<String, Object> jobGroups = this.endpoint.quartzJobGroups().getGroups();
assertThat(jobGroups).containsOnlyKeys("DEFAULT", "samples");
assertThat(jobGroups).extractingByKey("DEFAULT", nestedMap())
.containsOnly(entry("jobs", Arrays.asList("jobOne", "jobTwo")));
assertThat(jobGroups).extractingByKey("samples", nestedMap())
.containsOnly(entry("jobs", Collections.singletonList("jobThree")));
}
@Test
void quartzJobGroupsWithNoGroup() throws SchedulerException {
given(this.scheduler.getJobGroupNames()).willReturn(Collections.emptyList());
Map<String, Object> jobGroups = this.endpoint.quartzJobGroups().getGroups();
assertThat(jobGroups).isEmpty();
}
@Test
void quartzTriggerGroupsWithExistingGroups() throws SchedulerException {
mockTriggers(triggerOne, triggerTwo, triggerThree);
given(this.scheduler.getPausedTriggerGroups()).willReturn(Collections.singleton("samples"));
Map<String, Object> triggerGroups = this.endpoint.quartzTriggerGroups().getGroups();
assertThat(triggerGroups).containsOnlyKeys("DEFAULT", "samples");
assertThat(triggerGroups).extractingByKey("DEFAULT", nestedMap()).containsOnly(entry("paused", false),
entry("triggers", Arrays.asList("triggerOne", "triggerTwo")));
assertThat(triggerGroups).extractingByKey("samples", nestedMap()).containsOnly(entry("paused", true),
entry("triggers", Collections.singletonList("triggerThree")));
}
@Test
void quartzTriggerGroupsWithNoGroup() throws SchedulerException {
given(this.scheduler.getTriggerGroupNames()).willReturn(Collections.emptyList());
Map<String, Object> triggerGroups = this.endpoint.quartzTriggerGroups().getGroups();
assertThat(triggerGroups).isEmpty();
}
@Test
void quartzJobGroupSummaryWithInvalidGroup() throws SchedulerException {
given(this.scheduler.getJobGroupNames()).willReturn(Collections.singletonList("DEFAULT"));
QuartzJobGroupSummary summary = this.endpoint.quartzJobGroupSummary("unknown");
assertThat(summary).isNull();
}
@Test
void quartzJobGroupSummaryWithEmptyGroup() throws SchedulerException {
given(this.scheduler.getJobGroupNames()).willReturn(Collections.singletonList("samples"));
given(this.scheduler.getJobKeys(GroupMatcher.jobGroupEquals("samples"))).willReturn(Collections.emptySet());
QuartzJobGroupSummary summary = this.endpoint.quartzJobGroupSummary("samples");
assertThat(summary).isNotNull();
assertThat(summary.getGroup()).isEqualTo("samples");
assertThat(summary.getJobs()).isEmpty();
}
@Test
void quartzJobGroupSummaryWithJobs() throws SchedulerException {
mockJobs(jobOne, jobTwo);
QuartzJobGroupSummary summary = this.endpoint.quartzJobGroupSummary("DEFAULT");
assertThat(summary).isNotNull();
assertThat(summary.getGroup()).isEqualTo("DEFAULT");
Map<String, QuartzJobSummary> jobSummaries = summary.getJobs();
assertThat(jobSummaries).containsOnlyKeys("jobOne", "jobTwo");
assertThat(jobSummaries.get("jobOne").getClassName()).isEqualTo(Job.class.getName());
assertThat(jobSummaries.get("jobTwo").getClassName()).isEqualTo(DelegatingJob.class.getName());
}
@Test
void quartzTriggerGroupSummaryWithInvalidGroup() throws SchedulerException {
given(this.scheduler.getTriggerGroupNames()).willReturn(Collections.singletonList("DEFAULT"));
QuartzTriggerGroupSummary summary = this.endpoint.quartzTriggerGroupSummary("unknown");
assertThat(summary).isNull();
}
@Test
void quartzTriggerGroupSummaryWithEmptyGroup() throws SchedulerException {
given(this.scheduler.getTriggerGroupNames()).willReturn(Collections.singletonList("samples"));
given(this.scheduler.getTriggerKeys(GroupMatcher.triggerGroupEquals("samples")))
.willReturn(Collections.emptySet());
QuartzTriggerGroupSummary summary = this.endpoint.quartzTriggerGroupSummary("samples");
assertThat(summary).isNotNull();
assertThat(summary.getGroup()).isEqualTo("samples");
assertThat(summary.isPaused()).isFalse();
assertThat(summary.getTriggers().getCron()).isEmpty();
assertThat(summary.getTriggers().getSimple()).isEmpty();
assertThat(summary.getTriggers().getDailyTimeInterval()).isEmpty();
assertThat(summary.getTriggers().getCalendarInterval()).isEmpty();
assertThat(summary.getTriggers().getCustom()).isEmpty();
}
@Test
void quartzTriggerGroupSummaryWithCronTrigger() throws SchedulerException {
CronTrigger cronTrigger = TriggerBuilder.newTrigger().withIdentity("3am-every-day", "samples")
.withSchedule(CronScheduleBuilder.dailyAtHourAndMinute(3, 0)).build();
mockTriggers(cronTrigger);
QuartzTriggerGroupSummary summary = this.endpoint.quartzTriggerGroupSummary("samples");
assertThat(summary.getGroup()).isEqualTo("samples");
assertThat(summary.isPaused()).isFalse();
assertThat(summary.getTriggers().getCron()).containsOnlyKeys("3am-every-day");
assertThat(summary.getTriggers().getSimple()).isEmpty();
assertThat(summary.getTriggers().getDailyTimeInterval()).isEmpty();
assertThat(summary.getTriggers().getCalendarInterval()).isEmpty();
assertThat(summary.getTriggers().getCustom()).isEmpty();
}
@Test
void quartzTriggerGroupSummaryWithCronTriggerDetails() throws SchedulerException {
Date previousFireTime = Date.from(Instant.parse("2020-11-30T03:00:00Z"));
Date nextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z"));
TimeZone timeZone = TimeZone.getTimeZone("Europe/Paris");
CronTrigger cronTrigger = TriggerBuilder.newTrigger().withIdentity("3am-every-day", "samples").withPriority(3)
.withSchedule(CronScheduleBuilder.dailyAtHourAndMinute(3, 0).inTimeZone(timeZone)).build();
((OperableTrigger) cronTrigger).setPreviousFireTime(previousFireTime);
((OperableTrigger) cronTrigger).setNextFireTime(nextFireTime);
mockTriggers(cronTrigger);
QuartzTriggerGroupSummary summary = this.endpoint.quartzTriggerGroupSummary("samples");
Map<String, Object> triggers = summary.getTriggers().getCron();
assertThat(triggers).containsOnlyKeys("3am-every-day");
assertThat(triggers).extractingByKey("3am-every-day", nestedMap()).containsOnly(
entry("previousFireTime", previousFireTime), entry("nextFireTime", nextFireTime), entry("priority", 3),
entry("expression", "0 0 3 ? * *"), entry("timeZone", timeZone));
}
@Test
void quartzTriggerGroupSummaryWithSimpleTrigger() throws SchedulerException {
SimpleTrigger simpleTrigger = TriggerBuilder.newTrigger().withIdentity("every-hour", "samples")
.withSchedule(SimpleScheduleBuilder.repeatHourlyForever(1)).build();
mockTriggers(simpleTrigger);
QuartzTriggerGroupSummary summary = this.endpoint.quartzTriggerGroupSummary("samples");
assertThat(summary.getGroup()).isEqualTo("samples");
assertThat(summary.isPaused()).isFalse();
assertThat(summary.getTriggers().getCron()).isEmpty();
assertThat(summary.getTriggers().getSimple()).containsOnlyKeys("every-hour");
assertThat(summary.getTriggers().getDailyTimeInterval()).isEmpty();
assertThat(summary.getTriggers().getCalendarInterval()).isEmpty();
assertThat(summary.getTriggers().getCustom()).isEmpty();
}
@Test
void quartzTriggerGroupSummaryWithSimpleTriggerDetails() throws SchedulerException {
Date previousFireTime = Date.from(Instant.parse("2020-11-30T03:00:00Z"));
Date nextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z"));
SimpleTrigger simpleTrigger = TriggerBuilder.newTrigger().withIdentity("every-hour", "samples").withPriority(7)
.withSchedule(SimpleScheduleBuilder.repeatHourlyForever(1)).build();
((OperableTrigger) simpleTrigger).setPreviousFireTime(previousFireTime);
((OperableTrigger) simpleTrigger).setNextFireTime(nextFireTime);
mockTriggers(simpleTrigger);
QuartzTriggerGroupSummary summary = this.endpoint.quartzTriggerGroupSummary("samples");
Map<String, Object> triggers = summary.getTriggers().getSimple();
assertThat(triggers).containsOnlyKeys("every-hour");
assertThat(triggers).extractingByKey("every-hour", nestedMap()).containsOnly(
entry("previousFireTime", previousFireTime), entry("nextFireTime", nextFireTime), entry("priority", 7),
entry("interval", 3600000L));
}
@Test
void quartzTriggerGroupSummaryWithDailyIntervalTrigger() throws SchedulerException {
DailyTimeIntervalTrigger trigger = TriggerBuilder.newTrigger().withIdentity("every-hour-9am", "samples")
.withSchedule(DailyTimeIntervalScheduleBuilder.dailyTimeIntervalSchedule()
.startingDailyAt(TimeOfDay.hourAndMinuteOfDay(9, 0)).withInterval(1, IntervalUnit.HOUR))
.build();
mockTriggers(trigger);
QuartzTriggerGroupSummary summary = this.endpoint.quartzTriggerGroupSummary("samples");
assertThat(summary.getGroup()).isEqualTo("samples");
assertThat(summary.isPaused()).isFalse();
assertThat(summary.getTriggers().getCron()).isEmpty();
assertThat(summary.getTriggers().getSimple()).isEmpty();
assertThat(summary.getTriggers().getDailyTimeInterval()).containsOnlyKeys("every-hour-9am");
assertThat(summary.getTriggers().getCalendarInterval()).isEmpty();
assertThat(summary.getTriggers().getCustom()).isEmpty();
}
@Test
void quartzTriggerGroupSummaryWithDailyIntervalTriggerDetails() throws SchedulerException {
Date previousFireTime = Date.from(Instant.parse("2020-11-30T03:00:00Z"));
Date nextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z"));
DailyTimeIntervalTrigger trigger = TriggerBuilder.newTrigger().withIdentity("every-hour-tue-thu", "samples")
.withPriority(4)
.withSchedule(DailyTimeIntervalScheduleBuilder.dailyTimeIntervalSchedule()
.onDaysOfTheWeek(Calendar.TUESDAY, Calendar.THURSDAY)
.startingDailyAt(TimeOfDay.hourAndMinuteOfDay(9, 0))
.endingDailyAt(TimeOfDay.hourAndMinuteOfDay(18, 0)).withInterval(1, IntervalUnit.HOUR))
.build();
((OperableTrigger) trigger).setPreviousFireTime(previousFireTime);
((OperableTrigger) trigger).setNextFireTime(nextFireTime);
mockTriggers(trigger);
QuartzTriggerGroupSummary summary = this.endpoint.quartzTriggerGroupSummary("samples");
Map<String, Object> triggers = summary.getTriggers().getDailyTimeInterval();
assertThat(triggers).containsOnlyKeys("every-hour-tue-thu");
assertThat(triggers).extractingByKey("every-hour-tue-thu", nestedMap()).containsOnly(
entry("previousFireTime", previousFireTime), entry("nextFireTime", nextFireTime), entry("priority", 4),
entry("interval", 3600000L), entry("startTimeOfDay", LocalTime.of(9, 0)),
entry("endTimeOfDay", LocalTime.of(18, 0)),
entry("daysOfWeek", new LinkedHashSet<>(Arrays.asList(3, 5))));
}
@Test
void quartzTriggerGroupSummaryWithCalendarIntervalTrigger() throws SchedulerException {
CalendarIntervalTrigger trigger = TriggerBuilder.newTrigger().withIdentity("once-a-week", "samples")
.withSchedule(CalendarIntervalScheduleBuilder.calendarIntervalSchedule().withIntervalInWeeks(1))
.build();
mockTriggers(trigger);
QuartzTriggerGroupSummary summary = this.endpoint.quartzTriggerGroupSummary("samples");
assertThat(summary.getGroup()).isEqualTo("samples");
assertThat(summary.isPaused()).isFalse();
assertThat(summary.getTriggers().getCron()).isEmpty();
assertThat(summary.getTriggers().getSimple()).isEmpty();
assertThat(summary.getTriggers().getDailyTimeInterval()).isEmpty();
assertThat(summary.getTriggers().getCalendarInterval()).containsOnlyKeys("once-a-week");
assertThat(summary.getTriggers().getCustom()).isEmpty();
}
@Test
void quartzTriggerGroupSummaryWithCalendarIntervalTriggerDetails() throws SchedulerException {
TimeZone timeZone = TimeZone.getTimeZone("Europe/Paris");
Date previousFireTime = Date.from(Instant.parse("2020-11-30T03:00:00Z"));
Date nextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z"));
CalendarIntervalTrigger trigger = TriggerBuilder.newTrigger().withIdentity("once-a-week", "samples")
.withPriority(8).withSchedule(CalendarIntervalScheduleBuilder.calendarIntervalSchedule()
.withIntervalInWeeks(1).inTimeZone(timeZone))
.build();
((OperableTrigger) trigger).setPreviousFireTime(previousFireTime);
((OperableTrigger) trigger).setNextFireTime(nextFireTime);
mockTriggers(trigger);
QuartzTriggerGroupSummary summary = this.endpoint.quartzTriggerGroupSummary("samples");
Map<String, Object> triggers = summary.getTriggers().getCalendarInterval();
assertThat(triggers).containsOnlyKeys("once-a-week");
assertThat(triggers).extractingByKey("once-a-week", nestedMap()).containsOnly(
entry("previousFireTime", previousFireTime), entry("nextFireTime", nextFireTime), entry("priority", 8),
entry("interval", 604800000L), entry("timeZone", timeZone));
}
@Test
void quartzTriggerGroupSummaryWithCustomTrigger() throws SchedulerException {
Trigger trigger = mock(Trigger.class);
given(trigger.getKey()).willReturn(TriggerKey.triggerKey("custom", "samples"));
mockTriggers(trigger);
QuartzTriggerGroupSummary summary = this.endpoint.quartzTriggerGroupSummary("samples");
assertThat(summary.getGroup()).isEqualTo("samples");
assertThat(summary.isPaused()).isFalse();
assertThat(summary.getTriggers().getCron()).isEmpty();
assertThat(summary.getTriggers().getSimple()).isEmpty();
assertThat(summary.getTriggers().getDailyTimeInterval()).isEmpty();
assertThat(summary.getTriggers().getCalendarInterval()).isEmpty();
assertThat(summary.getTriggers().getCustom()).containsOnlyKeys("custom");
}
@Test
void quartzTriggerGroupSummaryWithCustomTriggerDetails() throws SchedulerException {
Date previousFireTime = Date.from(Instant.parse("2020-11-30T03:00:00Z"));
Date nextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z"));
Trigger trigger = mock(Trigger.class);
given(trigger.getKey()).willReturn(TriggerKey.triggerKey("custom", "samples"));
given(trigger.getPreviousFireTime()).willReturn(previousFireTime);
given(trigger.getNextFireTime()).willReturn(nextFireTime);
given(trigger.getPriority()).willReturn(9);
mockTriggers(trigger);
QuartzTriggerGroupSummary summary = this.endpoint.quartzTriggerGroupSummary("samples");
Map<String, Object> triggers = summary.getTriggers().getCustom();
assertThat(triggers).containsOnlyKeys("custom");
assertThat(triggers).extractingByKey("custom", nestedMap()).containsOnly(
entry("previousFireTime", previousFireTime), entry("nextFireTime", nextFireTime), entry("priority", 9),
entry("trigger", trigger.toString()));
}
@Test
void quartzTriggerWithCronTrigger() throws SchedulerException {
Date previousFireTime = Date.from(Instant.parse("2020-11-30T03:00:00Z"));
Date nextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z"));
TimeZone timeZone = TimeZone.getTimeZone("Europe/Paris");
CronTrigger trigger = TriggerBuilder.newTrigger().withIdentity("3am-every-day", "samples").withPriority(3)
.withDescription("Sample description")
.withSchedule(CronScheduleBuilder.dailyAtHourAndMinute(3, 0).inTimeZone(timeZone)).build();
((OperableTrigger) trigger).setPreviousFireTime(previousFireTime);
((OperableTrigger) trigger).setNextFireTime(nextFireTime);
mockTriggers(trigger);
given(this.scheduler.getTriggerState(TriggerKey.triggerKey("3am-every-day", "samples")))
.willReturn(TriggerState.NORMAL);
Map<String, Object> triggerDetails = this.endpoint.quartzTrigger("samples", "3am-every-day");
assertThat(triggerDetails).contains(entry("group", "samples"), entry("name", "3am-every-day"),
entry("description", "Sample description"), entry("type", "cron"), entry("state", TriggerState.NORMAL),
entry("priority", 3));
assertThat(triggerDetails).contains(entry("previousFireTime", previousFireTime),
entry("nextFireTime", nextFireTime));
assertThat(triggerDetails).doesNotContainKeys("simple", "dailyTimeInterval", "calendarInterval", "custom");
assertThat(triggerDetails).extractingByKey("cron", nestedMap()).containsOnly(entry("expression", "0 0 3 ? * *"),
entry("timeZone", timeZone));
}
@Test
void quartzTriggerWithSimpleTrigger() throws SchedulerException {
Date startTime = Date.from(Instant.parse("2020-01-01T09:00:00Z"));
Date previousFireTime = Date.from(Instant.parse("2020-11-30T03:00:00Z"));
Date nextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z"));
Date endTime = Date.from(Instant.parse("2020-01-31T09:00:00Z"));
SimpleTrigger trigger = TriggerBuilder.newTrigger().withIdentity("every-hour", "samples").withPriority(20)
.withDescription("Every hour").startAt(startTime).endAt(endTime)
.withSchedule(SimpleScheduleBuilder.simpleSchedule().withIntervalInHours(1).withRepeatCount(2000))
.build();
((OperableTrigger) trigger).setPreviousFireTime(previousFireTime);
((OperableTrigger) trigger).setNextFireTime(nextFireTime);
mockTriggers(trigger);
given(this.scheduler.getTriggerState(TriggerKey.triggerKey("every-hour", "samples")))
.willReturn(TriggerState.COMPLETE);
Map<String, Object> triggerDetails = this.endpoint.quartzTrigger("samples", "every-hour");
assertThat(triggerDetails).contains(entry("group", "samples"), entry("name", "every-hour"),
entry("description", "Every hour"), entry("type", "simple"), entry("state", TriggerState.COMPLETE),
entry("priority", 20));
assertThat(triggerDetails).contains(entry("startTime", startTime), entry("previousFireTime", previousFireTime),
entry("nextFireTime", nextFireTime), entry("endTime", endTime));
assertThat(triggerDetails).doesNotContainKeys("cron", "dailyTimeInterval", "calendarInterval", "custom");
assertThat(triggerDetails).extractingByKey("simple", nestedMap()).containsOnly(entry("interval", 3600000L),
entry("repeatCount", 2000), entry("timesTriggered", 0));
}
@Test
void quartzTriggerWithDailyTimeIntervalTrigger() throws SchedulerException {
Date previousFireTime = Date.from(Instant.parse("2020-11-30T03:00:00Z"));
Date nextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z"));
DailyTimeIntervalTrigger trigger = TriggerBuilder.newTrigger().withIdentity("every-hour-mon-wed", "samples")
.withDescription("Every working hour Mon Wed").withPriority(4)
.withSchedule(DailyTimeIntervalScheduleBuilder.dailyTimeIntervalSchedule()
.onDaysOfTheWeek(Calendar.MONDAY, Calendar.WEDNESDAY)
.startingDailyAt(TimeOfDay.hourAndMinuteOfDay(9, 0))
.endingDailyAt(TimeOfDay.hourAndMinuteOfDay(18, 0)).withInterval(1, IntervalUnit.HOUR))
.build();
((OperableTrigger) trigger).setPreviousFireTime(previousFireTime);
((OperableTrigger) trigger).setNextFireTime(nextFireTime);
mockTriggers(trigger);
given(this.scheduler.getTriggerState(TriggerKey.triggerKey("every-hour-mon-wed", "samples")))
.willReturn(TriggerState.NORMAL);
Map<String, Object> triggerDetails = this.endpoint.quartzTrigger("samples", "every-hour-mon-wed");
assertThat(triggerDetails).contains(entry("group", "samples"), entry("name", "every-hour-mon-wed"),
entry("description", "Every working hour Mon Wed"), entry("type", "dailyTimeInterval"),
entry("state", TriggerState.NORMAL), entry("priority", 4));
assertThat(triggerDetails).contains(entry("previousFireTime", previousFireTime),
entry("nextFireTime", nextFireTime));
assertThat(triggerDetails).doesNotContainKeys("cron", "simple", "calendarInterval", "custom");
assertThat(triggerDetails).extractingByKey("dailyTimeInterval", nestedMap()).containsOnly(
entry("interval", 3600000L), entry("startTimeOfDay", LocalTime.of(9, 0)),
entry("endTimeOfDay", LocalTime.of(18, 0)),
entry("daysOfWeek", new LinkedHashSet<>(Arrays.asList(2, 4))), entry("repeatCount", -1),
entry("timesTriggered", 0));
}
@Test
void quartzTriggerWithCalendarTimeIntervalTrigger() throws SchedulerException {
TimeZone timeZone = TimeZone.getTimeZone("Europe/Paris");
Date previousFireTime = Date.from(Instant.parse("2020-11-30T03:00:00Z"));
Date nextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z"));
CalendarIntervalTrigger trigger = TriggerBuilder.newTrigger().withIdentity("once-a-week", "samples")
.withDescription("Once a week").withPriority(8)
.withSchedule(CalendarIntervalScheduleBuilder.calendarIntervalSchedule().withIntervalInWeeks(1)
.inTimeZone(timeZone).preserveHourOfDayAcrossDaylightSavings(true))
.build();
((OperableTrigger) trigger).setPreviousFireTime(previousFireTime);
((OperableTrigger) trigger).setNextFireTime(nextFireTime);
mockTriggers(trigger);
given(this.scheduler.getTriggerState(TriggerKey.triggerKey("once-a-week", "samples")))
.willReturn(TriggerState.BLOCKED);
Map<String, Object> triggerDetails = this.endpoint.quartzTrigger("samples", "once-a-week");
assertThat(triggerDetails).contains(entry("group", "samples"), entry("name", "once-a-week"),
entry("description", "Once a week"), entry("type", "calendarInterval"),
entry("state", TriggerState.BLOCKED), entry("priority", 8));
assertThat(triggerDetails).contains(entry("previousFireTime", previousFireTime),
entry("nextFireTime", nextFireTime));
assertThat(triggerDetails).doesNotContainKeys("cron", "simple", "dailyTimeInterval", "custom");
assertThat(triggerDetails).extractingByKey("calendarInterval", nestedMap()).containsOnly(
entry("interval", 604800000L), entry("timeZone", timeZone),
entry("preserveHourOfDayAcrossDaylightSavings", true), entry("skipDayIfHourDoesNotExist", false),
entry("timesTriggered", 0));
}
@Test
void quartzTriggerWithCustomTrigger() throws SchedulerException {
Date previousFireTime = Date.from(Instant.parse("2020-11-30T03:00:00Z"));
Date nextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z"));
Trigger trigger = mock(Trigger.class);
given(trigger.getKey()).willReturn(TriggerKey.triggerKey("custom", "samples"));
given(trigger.getPreviousFireTime()).willReturn(previousFireTime);
given(trigger.getNextFireTime()).willReturn(nextFireTime);
given(trigger.getPriority()).willReturn(9);
mockTriggers(trigger);
given(this.scheduler.getTriggerState(TriggerKey.triggerKey("custom", "samples")))
.willReturn(TriggerState.ERROR);
Map<String, Object> triggerDetails = this.endpoint.quartzTrigger("samples", "custom");
assertThat(triggerDetails).contains(entry("group", "samples"), entry("name", "custom"), entry("type", "custom"),
entry("state", TriggerState.ERROR), entry("priority", 9));
assertThat(triggerDetails).contains(entry("previousFireTime", previousFireTime),
entry("nextFireTime", nextFireTime));
assertThat(triggerDetails).doesNotContainKeys("cron", "simple", "calendarInterval", "dailyTimeInterval");
assertThat(triggerDetails).extractingByKey("custom", nestedMap())
.containsOnly(entry("trigger", trigger.toString()));
}
@Test
void quartzTriggerWithDataMap() throws SchedulerException {
CronTrigger trigger = TriggerBuilder.newTrigger().withIdentity("3am-every-day", "samples")
.withSchedule(CronScheduleBuilder.dailyAtHourAndMinute(3, 0)).usingJobData("user", "user")
.usingJobData("password", "secret").usingJobData("url", "https://user:secret@example.com").build();
mockTriggers(trigger);
given(this.scheduler.getTriggerState(TriggerKey.triggerKey("3am-every-day", "samples")))
.willReturn(TriggerState.NORMAL);
Map<String, Object> triggerDetails = this.endpoint.quartzTrigger("samples", "3am-every-day");
assertThat(triggerDetails).extractingByKey("data", nestedMap()).containsOnly(entry("user", "user"),
entry("password", "******"), entry("url", "https://user:******@example.com"));
}
@ParameterizedTest(name = "unit {1}")
@MethodSource("intervalUnitParameters")
void canConvertIntervalUnit(int amount, IntervalUnit unit, Duration expectedDuration) throws SchedulerException {
CalendarIntervalTrigger trigger = TriggerBuilder.newTrigger().withIdentity("trigger", "samples")
.withSchedule(CalendarIntervalScheduleBuilder.calendarIntervalSchedule().withInterval(amount, unit))
.build();
mockTriggers(trigger);
Map<String, Object> triggerDetails = this.endpoint.quartzTrigger("samples", "trigger");
assertThat(triggerDetails).extractingByKey("calendarInterval", nestedMap())
.contains(entry("interval", expectedDuration.toMillis()));
}
static Stream<Arguments> intervalUnitParameters() {
return Stream.of(Arguments.of(3, IntervalUnit.DAY, Duration.ofDays(3)),
Arguments.of(2, IntervalUnit.HOUR, Duration.ofHours(2)),
Arguments.of(5, IntervalUnit.MINUTE, Duration.ofMinutes(5)),
Arguments.of(1, IntervalUnit.MONTH, ChronoUnit.MONTHS.getDuration()),
Arguments.of(30, IntervalUnit.SECOND, Duration.ofSeconds(30)),
Arguments.of(100, IntervalUnit.MILLISECOND, Duration.ofMillis(100)),
Arguments.of(1, IntervalUnit.WEEK, ChronoUnit.WEEKS.getDuration()),
Arguments.of(1, IntervalUnit.YEAR, ChronoUnit.YEARS.getDuration()));
}
@Test
void quartzJobWithoutTrigger() throws SchedulerException {
JobDetail job = JobBuilder.newJob(Job.class).withIdentity("hello", "samples").withDescription("A sample job")
.storeDurably().requestRecovery(false).build();
mockJobs(job);
QuartzJobDetails jobDetails = this.endpoint.quartzJob("samples", "hello");
assertThat(jobDetails.getGroup()).isEqualTo("samples");
assertThat(jobDetails.getName()).isEqualTo("hello");
assertThat(jobDetails.getDescription()).isEqualTo("A sample job");
assertThat(jobDetails.getClassName()).isEqualTo(Job.class.getName());
assertThat(jobDetails.isDurable()).isTrue();
assertThat(jobDetails.isRequestRecovery()).isFalse();
assertThat(jobDetails.getData()).isEmpty();
assertThat(jobDetails.getTriggers()).isEmpty();
}
@Test
void quartzJobWithTrigger() throws SchedulerException {
Date previousFireTime = Date.from(Instant.parse("2020-11-30T03:00:00Z"));
Date nextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z"));
JobDetail job = JobBuilder.newJob(Job.class).withIdentity("hello", "samples").build();
TimeZone timeZone = TimeZone.getTimeZone("Europe/Paris");
Trigger trigger = TriggerBuilder.newTrigger().withIdentity("3am-every-day", "samples").withPriority(4)
.withSchedule(CronScheduleBuilder.dailyAtHourAndMinute(3, 0).inTimeZone(timeZone)).build();
((OperableTrigger) trigger).setPreviousFireTime(previousFireTime);
((OperableTrigger) trigger).setNextFireTime(nextFireTime);
mockJobs(job);
mockTriggers(trigger);
given(this.scheduler.getTriggersOfJob(JobKey.jobKey("hello", "samples")))
.willAnswer((invocation) -> Collections.singletonList(trigger));
QuartzJobDetails jobDetails = this.endpoint.quartzJob("samples", "hello");
assertThat(jobDetails.getTriggers()).hasSize(1);
Map<String, Object> triggerDetails = jobDetails.getTriggers().get(0);
assertThat(triggerDetails).containsOnly(entry("group", "samples"), entry("name", "3am-every-day"),
entry("previousFireTime", previousFireTime), entry("nextFireTime", nextFireTime), entry("priority", 4));
}
@Test
void quartzJobOrdersTriggersAccordingToNextFireTime() throws SchedulerException {
JobDetail job = JobBuilder.newJob(Job.class).withIdentity("hello", "samples").build();
mockJobs(job);
Date triggerOneNextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z"));
CronTrigger triggerOne = TriggerBuilder.newTrigger().withIdentity("one", "samples").withPriority(5)
.withSchedule(CronScheduleBuilder.dailyAtHourAndMinute(3, 0)).build();
((OperableTrigger) triggerOne).setNextFireTime(triggerOneNextFireTime);
Date triggerTwoNextFireTime = Date.from(Instant.parse("2020-12-01T02:00:00Z"));
CronTrigger triggerTwo = TriggerBuilder.newTrigger().withIdentity("two", "samples").withPriority(10)
.withSchedule(CronScheduleBuilder.dailyAtHourAndMinute(2, 0)).build();
((OperableTrigger) triggerTwo).setNextFireTime(triggerTwoNextFireTime);
mockTriggers(triggerOne, triggerTwo);
given(this.scheduler.getTriggersOfJob(JobKey.jobKey("hello", "samples")))
.willAnswer((invocation) -> Arrays.asList(triggerOne, triggerTwo));
QuartzJobDetails jobDetails = this.endpoint.quartzJob("samples", "hello");
assertThat(jobDetails.getTriggers()).hasSize(2);
assertThat(jobDetails.getTriggers().get(0)).containsEntry("name", "two");
assertThat(jobDetails.getTriggers().get(1)).containsEntry("name", "one");
}
@Test
public void quartzReport() throws Exception {
String jobGroup = this.jobDetail.getKey().getGroup();
given(this.scheduler.getJobGroupNames()).willReturn(Collections.singletonList(jobGroup));
given(this.scheduler.getJobKeys(GroupMatcher.jobGroupEquals(jobGroup)))
.willReturn(Collections.singleton(this.jobDetail.getKey()));
Map<String, Object> quartzReport = this.endpoint.quartzReport();
assertThat(quartzReport).hasSize(1);
}
@Test
public void quartzJob() throws Exception {
JobKey jobKey = this.jobDetail.getKey();
given(this.scheduler.getJobDetail(jobKey)).willReturn(this.jobDetail);
given(this.scheduler.getTriggersOfJob(jobKey))
.willAnswer(invocation -> Collections.singletonList(this.trigger));
QuartzJob quartzJob = this.endpoint.quartzJob(jobKey.getGroup(), jobKey.getName());
assertThat(quartzJob.getJobGroup()).isEqualTo(jobKey.getGroup());
assertThat(quartzJob.getJobName()).isEqualTo(jobKey.getName());
assertThat(quartzJob.getClassName()).isEqualTo(this.jobDetail.getJobClass().getName());
assertThat(quartzJob.getTriggers()).hasSize(1);
assertThat(quartzJob.getTriggers().get(0).getTriggerGroup()).isEqualTo(this.trigger.getKey().getGroup());
assertThat(quartzJob.getTriggers().get(0).getTriggerName()).isEqualTo(this.trigger.getKey().getName());
void quartzJobOrdersTriggersAccordingNextFireTimeAndPriority() throws SchedulerException {
JobDetail job = JobBuilder.newJob(Job.class).withIdentity("hello", "samples").build();
mockJobs(job);
Date nextFireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z"));
CronTrigger triggerOne = TriggerBuilder.newTrigger().withIdentity("one", "samples").withPriority(3)
.withSchedule(CronScheduleBuilder.dailyAtHourAndMinute(3, 0)).build();
((OperableTrigger) triggerOne).setNextFireTime(nextFireTime);
CronTrigger triggerTwo = TriggerBuilder.newTrigger().withIdentity("two", "samples").withPriority(7)
.withSchedule(CronScheduleBuilder.dailyAtHourAndMinute(3, 0)).build();
((OperableTrigger) triggerTwo).setNextFireTime(nextFireTime);
mockTriggers(triggerOne, triggerTwo);
given(this.scheduler.getTriggersOfJob(JobKey.jobKey("hello", "samples")))
.willAnswer((invocation) -> Arrays.asList(triggerOne, triggerTwo));
QuartzJobDetails jobDetails = this.endpoint.quartzJob("samples", "hello");
assertThat(jobDetails.getTriggers()).hasSize(2);
assertThat(jobDetails.getTriggers().get(0)).containsEntry("name", "two");
assertThat(jobDetails.getTriggers().get(1)).containsEntry("name", "one");
}
@Test
void quartzJobWithSensitiveDataMap() throws SchedulerException {
JobDetail job = JobBuilder.newJob(Job.class).withIdentity("hello", "samples").usingJobData("user", "user")
.usingJobData("password", "secret").usingJobData("url", "https://user:secret@example.com").build();
mockJobs(job);
QuartzJobDetails jobDetails = this.endpoint.quartzJob("samples", "hello");
assertThat(jobDetails.getData()).containsOnly(entry("user", "user"), entry("password", "******"),
entry("url", "https://user:******@example.com"));
}
@Test
void quartzJobWithSensitiveDataMapAndCustomSanitizier() throws SchedulerException {
JobDetail job = JobBuilder.newJob(Job.class).withIdentity("hello", "samples").usingJobData("test", "value")
.usingJobData("secret", "value").build();
mockJobs(job);
Sanitizer sanitizer = mock(Sanitizer.class);
given(sanitizer.sanitize("test", "value")).willReturn("value");
given(sanitizer.sanitize("secret", "value")).willReturn("----");
QuartzJobDetails jobDetails = new QuartzEndpoint(this.scheduler, sanitizer).quartzJob("samples", "hello");
assertThat(jobDetails.getData()).containsOnly(entry("test", "value"), entry("secret", "----"));
verify(sanitizer).sanitize("test", "value");
verify(sanitizer).sanitize("secret", "value");
verifyNoMoreInteractions(sanitizer);
}
private void mockJobs(JobDetail... jobs) throws SchedulerException {
MultiValueMap<String, JobKey> jobKeys = new LinkedMultiValueMap<>();
for (JobDetail jobDetail : jobs) {
JobKey key = jobDetail.getKey();
given(this.scheduler.getJobDetail(key)).willReturn(jobDetail);
jobKeys.add(key.getGroup(), key);
}
given(this.scheduler.getJobGroupNames()).willReturn(new ArrayList<>(jobKeys.keySet()));
for (Entry<String, List<JobKey>> entry : jobKeys.entrySet()) {
given(this.scheduler.getJobKeys(GroupMatcher.jobGroupEquals(entry.getKey())))
.willReturn(new LinkedHashSet<>(entry.getValue()));
}
}
private void mockTriggers(Trigger... triggers) throws SchedulerException {
MultiValueMap<String, TriggerKey> triggerKeys = new LinkedMultiValueMap<>();
for (Trigger trigger : triggers) {
TriggerKey key = trigger.getKey();
given(this.scheduler.getTrigger(key)).willReturn(trigger);
triggerKeys.add(key.getGroup(), key);
}
given(this.scheduler.getTriggerGroupNames()).willReturn(new ArrayList<>(triggerKeys.keySet()));
for (Entry<String, List<TriggerKey>> entry : triggerKeys.entrySet()) {
given(this.scheduler.getTriggerKeys(GroupMatcher.triggerGroupEquals(entry.getKey())))
.willReturn(new LinkedHashSet<>(entry.getValue()));
}
}
@SuppressWarnings("rawtypes")
private static InstanceOfAssertFactory<Map, MapAssert<String, Object>> nestedMap() {
return InstanceOfAssertFactories.map(String.class, Object.class);
}
}

@ -0,0 +1,217 @@
/*
* Copyright 2012-2019 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.actuate.quartz;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map.Entry;
import net.minidev.json.JSONArray;
import org.quartz.CalendarIntervalScheduleBuilder;
import org.quartz.CalendarIntervalTrigger;
import org.quartz.CronScheduleBuilder;
import org.quartz.CronTrigger;
import org.quartz.Job;
import org.quartz.JobBuilder;
import org.quartz.JobDataMap;
import org.quartz.JobDetail;
import org.quartz.JobKey;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.SimpleScheduleBuilder;
import org.quartz.SimpleTrigger;
import org.quartz.Trigger;
import org.quartz.Trigger.TriggerState;
import org.quartz.TriggerBuilder;
import org.quartz.TriggerKey;
import org.quartz.impl.matchers.GroupMatcher;
import org.springframework.boot.actuate.endpoint.web.test.WebEndpointTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.quartz.DelegatingJob;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/**
* Integration tests for {@link QuartzEndpoint} exposed by Jersey, Spring MVC, and
* WebFlux.
*
* @author Stephane Nicoll
*/
class QuartzEndpointWebIntegrationTests {
private static final JobDetail jobOne = JobBuilder.newJob(Job.class).withIdentity("jobOne", "samples")
.usingJobData(new JobDataMap(Collections.singletonMap("name", "test"))).withDescription("A sample job")
.build();
private static final JobDetail jobTwo = JobBuilder.newJob(DelegatingJob.class).withIdentity("jobTwo", "samples")
.build();
private static final JobDetail jobThree = JobBuilder.newJob(Job.class).withIdentity("jobThree").build();
private static final CronTrigger triggerOne = TriggerBuilder.newTrigger().withDescription("Once a day 3AM")
.withIdentity("triggerOne").withSchedule(CronScheduleBuilder.dailyAtHourAndMinute(3, 0)).build();
private static final SimpleTrigger triggerTwo = TriggerBuilder.newTrigger().withDescription("Once a day")
.withIdentity("triggerTwo", "tests").withSchedule(SimpleScheduleBuilder.repeatHourlyForever(24)).build();
private static final CalendarIntervalTrigger triggerThree = TriggerBuilder.newTrigger()
.withDescription("Once a week").withIdentity("triggerThree", "tests")
.withSchedule(CalendarIntervalScheduleBuilder.calendarIntervalSchedule().withIntervalInWeeks(1)).build();
@WebEndpointTest
void quartzReport(WebTestClient client) {
client.get().uri("/actuator/quartz").exchange().expectStatus().isOk().expectBody().jsonPath("jobs.groups")
.isEqualTo(new JSONArray().appendElement("samples").appendElement("DEFAULT"))
.jsonPath("triggers.groups").isEqualTo(new JSONArray().appendElement("DEFAULT").appendElement("tests"));
}
@WebEndpointTest
void quartzJobNames(WebTestClient client) {
client.get().uri("/actuator/quartz/jobs").exchange().expectStatus().isOk().expectBody()
.jsonPath("groups.samples.jobs")
.isEqualTo(new JSONArray().appendElement("jobOne").appendElement("jobTwo"))
.jsonPath("groups.DEFAULT.jobs").isEqualTo(new JSONArray().appendElement("jobThree"));
}
@WebEndpointTest
void quartzTriggerNames(WebTestClient client) {
client.get().uri("/actuator/quartz/triggers").exchange().expectStatus().isOk().expectBody()
.jsonPath("groups.DEFAULT.paused").isEqualTo(false).jsonPath("groups.DEFAULT.triggers")
.isEqualTo(new JSONArray().appendElement("triggerOne")).jsonPath("groups.tests.paused").isEqualTo(false)
.jsonPath("groups.tests.triggers")
.isEqualTo(new JSONArray().appendElement("triggerTwo").appendElement("triggerThree"));
}
@WebEndpointTest
void quartzTriggersOrJobsAreAllowed(WebTestClient client) {
client.get().uri("/actuator/quartz/something-elese").exchange().expectStatus().isBadRequest();
}
@WebEndpointTest
void quartzJobGroupSummary(WebTestClient client) {
client.get().uri("/actuator/quartz/jobs/samples").exchange().expectStatus().isOk().expectBody()
.jsonPath("group").isEqualTo("samples").jsonPath("jobs.jobOne.className").isEqualTo(Job.class.getName())
.jsonPath("jobs.jobTwo.className").isEqualTo(DelegatingJob.class.getName());
}
@WebEndpointTest
void quartzJobGroupSummaryWithUnknownGroup(WebTestClient client) {
client.get().uri("/actuator/quartz/jobs/does-not-exist").exchange().expectStatus().isNotFound();
}
@WebEndpointTest
void quartzTriggerGroupSummary(WebTestClient client) {
client.get().uri("/actuator/quartz/triggers/tests").exchange().expectStatus().isOk().expectBody()
.jsonPath("group").isEqualTo("tests").jsonPath("paused").isEqualTo("false").jsonPath("triggers.cron")
.isEmpty().jsonPath("triggers.simple.triggerTwo.interval").isEqualTo(86400000)
.jsonPath("triggers.dailyTimeInterval").isEmpty()
.jsonPath("triggers.calendarInterval.triggerThree.interval").isEqualTo(604800000)
.jsonPath("triggers.custom").isEmpty();
}
@WebEndpointTest
void quartzTriggerGroupSummaryWithUnknownGroup(WebTestClient client) {
client.get().uri("/actuator/quartz/triggers/does-not-exist").exchange().expectStatus().isNotFound();
}
@WebEndpointTest
void quartzJobDetail(WebTestClient client) {
client.get().uri("/actuator/quartz/jobs/samples/jobOne").exchange().expectStatus().isOk().expectBody()
.jsonPath("group").isEqualTo("samples").jsonPath("name").isEqualTo("jobOne").jsonPath("data.name")
.isEqualTo("test");
}
@WebEndpointTest
void quartzJobDetailWithUnknownKey(WebTestClient client) {
client.get().uri("/actuator/quartz/jobs/samples/does-not-exist").exchange().expectStatus().isNotFound();
}
@WebEndpointTest
void quartzTriggerDetail(WebTestClient client) {
client.get().uri("/actuator/quartz/triggers/DEFAULT/triggerOne").exchange().expectStatus().isOk().expectBody()
.jsonPath("group").isEqualTo("DEFAULT").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 ? * *");
}
@WebEndpointTest
void quartzTriggerDetailWithUnknownKey(WebTestClient client) {
client.get().uri("/actuator/quartz/triggers/tests/does-not-exist").exchange().expectStatus().isNotFound();
}
@Configuration(proxyBeanMethods = false)
static class TestConfiguration {
@Bean
Scheduler scheduler() throws SchedulerException {
Scheduler scheduler = mock(Scheduler.class);
mockJobs(scheduler, jobOne, jobTwo, jobThree);
mockTriggers(scheduler, triggerOne, triggerTwo, triggerThree);
return scheduler;
}
@Bean
QuartzEndpoint endpoint(Scheduler scheduler) {
return new QuartzEndpoint(scheduler);
}
@Bean
QuartzEndpointWebExtension quartzEndpointWebExtension(QuartzEndpoint endpoint) {
return new QuartzEndpointWebExtension(endpoint);
}
private void mockJobs(Scheduler scheduler, JobDetail... jobs) throws SchedulerException {
MultiValueMap<String, JobKey> jobKeys = new LinkedMultiValueMap<>();
for (JobDetail jobDetail : jobs) {
JobKey key = jobDetail.getKey();
given(scheduler.getJobDetail(key)).willReturn(jobDetail);
jobKeys.add(key.getGroup(), key);
}
given(scheduler.getJobGroupNames()).willReturn(new ArrayList<>(jobKeys.keySet()));
for (Entry<String, List<JobKey>> entry : jobKeys.entrySet()) {
given(scheduler.getJobKeys(GroupMatcher.jobGroupEquals(entry.getKey())))
.willReturn(new LinkedHashSet<>(entry.getValue()));
}
}
void mockTriggers(Scheduler scheduler, Trigger... triggers) throws SchedulerException {
MultiValueMap<String, TriggerKey> triggerKeys = new LinkedMultiValueMap<>();
for (Trigger trigger : triggers) {
TriggerKey key = trigger.getKey();
given(scheduler.getTrigger(key)).willReturn(trigger);
given(scheduler.getTriggerState(key)).willReturn(TriggerState.NORMAL);
triggerKeys.add(key.getGroup(), key);
}
given(scheduler.getTriggerGroupNames()).willReturn(new ArrayList<>(triggerKeys.keySet()));
for (Entry<String, List<TriggerKey>> entry : triggerKeys.entrySet()) {
given(scheduler.getTriggerKeys(GroupMatcher.triggerGroupEquals(entry.getKey())))
.willReturn(new LinkedHashSet<>(entry.getValue()));
}
}
}
}

@ -112,6 +112,9 @@ The following technology-agnostic endpoints are available:
| `mappings`
| Displays a collated list of all `@RequestMapping` paths.
|`quartz`
|Shows information about Quartz Scheduler jobs.
| `scheduledtasks`
| Displays the scheduled tasks in your application.
@ -272,6 +275,10 @@ The following table shows the default exposure for the built-in endpoints:
| N/A
| No
| `quartz`
| Yes
| No
| `scheduledtasks`
| Yes
| No

@ -6,8 +6,10 @@ plugins {
description = "Spring Boot Quartz smoke test"
dependencies {
implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-jdbc"))
implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-web"))
implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-actuator"))
implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-quartz"))
implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-jdbc"))
runtimeOnly("com.h2database:h2")

@ -1,5 +1,5 @@
/*
* Copyright 2012-2019 the original author or authors.
* Copyright 2012-2021 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,9 +16,16 @@
package smoketest.quartz;
import java.util.Calendar;
import org.quartz.CalendarIntervalScheduleBuilder;
import org.quartz.CronScheduleBuilder;
import org.quartz.DailyTimeIntervalScheduleBuilder;
import org.quartz.DateBuilder.IntervalUnit;
import org.quartz.JobBuilder;
import org.quartz.JobDetail;
import org.quartz.SimpleScheduleBuilder;
import org.quartz.TimeOfDay;
import org.quartz.Trigger;
import org.quartz.TriggerBuilder;
@ -34,18 +41,50 @@ public class SampleQuartzApplication {
}
@Bean
public JobDetail sampleJobDetail() {
return JobBuilder.newJob(SampleJob.class).withIdentity("sampleJob").usingJobData("name", "World").storeDurably()
.build();
public JobDetail helloJobDetail() {
return JobBuilder.newJob(SampleJob.class).withIdentity("helloJob", "samples").usingJobData("name", "World")
.storeDurably().build();
}
@Bean
public JobDetail anotherJobDetail() {
return JobBuilder.newJob(SampleJob.class).withIdentity("anotherJob", "samples").usingJobData("name", "Everyone")
.storeDurably().build();
}
@Bean
public Trigger everyTwoSecTrigger() {
return TriggerBuilder.newTrigger().forJob("helloJob", "samples").withIdentity("sampleTrigger")
.withSchedule(SimpleScheduleBuilder.simpleSchedule().withIntervalInSeconds(2).repeatForever()).build();
}
@Bean
public Trigger everyDayTrigger() {
return TriggerBuilder.newTrigger().forJob("helloJob", "samples").withIdentity("every-day", "samples")
.withSchedule(SimpleScheduleBuilder.repeatHourlyForever(24)).build();
}
@Bean
public Trigger sampleJobTrigger() {
SimpleScheduleBuilder scheduleBuilder = SimpleScheduleBuilder.simpleSchedule().withIntervalInSeconds(2)
.repeatForever();
public Trigger threeAmWeekdaysTrigger() {
return TriggerBuilder.newTrigger().forJob("anotherJob", "samples").withIdentity("3am-weekdays", "samples")
.withSchedule(CronScheduleBuilder.atHourAndMinuteOnGivenDaysOfWeek(3, 0, 1, 2, 3, 4, 5)).build();
}
return TriggerBuilder.newTrigger().forJob(sampleJobDetail()).withIdentity("sampleTrigger")
.withSchedule(scheduleBuilder).build();
@Bean
public Trigger onceAWeekTrigger() {
return TriggerBuilder.newTrigger().forJob("anotherJob", "samples").withIdentity("once-a-week", "samples")
.withSchedule(CalendarIntervalScheduleBuilder.calendarIntervalSchedule().withIntervalInWeeks(1))
.build();
}
@Bean
public Trigger everyHourWorkingHourTuesdayAndThursdayTrigger() {
return TriggerBuilder.newTrigger().forJob("helloJob", "samples").withIdentity("every-hour-tue-thu", "samples")
.withSchedule(DailyTimeIntervalScheduleBuilder.dailyTimeIntervalSchedule()
.onDaysOfTheWeek(Calendar.TUESDAY, Calendar.THURSDAY)
.startingDailyAt(TimeOfDay.hourAndMinuteOfDay(9, 0))
.endingDailyAt(TimeOfDay.hourAndMinuteOfDay(18, 0)).withInterval(1, IntervalUnit.HOUR))
.build();
}
}

@ -1 +1,3 @@
spring.quartz.job-store-type=jdbc
management.endpoints.web.exposure.include=health,quartz

@ -1,5 +1,5 @@
/*
* Copyright 2012-2019 the original author or authors.
* Copyright 2012-2021 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.
@ -38,8 +38,9 @@ import static org.hamcrest.Matchers.containsString;
class SampleQuartzApplicationTests {
@Test
void quartzJobIsTriggered(CapturedOutput output) throws InterruptedException {
try (ConfigurableApplicationContext context = SpringApplication.run(SampleQuartzApplication.class)) {
void quartzJobIsTriggered(CapturedOutput output) {
try (ConfigurableApplicationContext context = SpringApplication.run(SampleQuartzApplication.class,
"--server.port=0")) {
Awaitility.waitAtMost(Duration.ofSeconds(5)).until(output::toString, containsString("Hello World!"));
}
}

@ -0,0 +1,110 @@
/*
* Copyright 2012-2021 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package smoketest.quartz;
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.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.web.client.TestRestTemplate;
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;
/**
* Web tests for {@link SampleQuartzApplication}.
*
* @author Stephane Nicoll
*/
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class SampleQuartzApplicationWebTests {
@Autowired
private TestRestTemplate restTemplate;
@Test
void quartzGroupNames() {
Map<String, Object> content = getContent("/actuator/quartz");
assertThat(content).containsOnlyKeys("jobs", "triggers");
}
@Test
void quartzJobGroups() {
Map<String, Object> content = getContent("/actuator/quartz/jobs");
assertThat(content).containsOnlyKeys("groups");
assertThat(content).extractingByKey("groups", nestedMap()).containsOnlyKeys("samples");
}
@Test
void quartzTriggerGroups() {
Map<String, Object> content = getContent("/actuator/quartz/triggers");
assertThat(content).containsOnlyKeys("groups");
assertThat(content).extractingByKey("groups", nestedMap()).containsOnlyKeys("DEFAULT", "samples");
}
@Test
void quartzJobDetail() {
Map<String, Object> content = getContent("/actuator/quartz/jobs/samples/helloJob");
assertThat(content).containsEntry("name", "helloJob").containsEntry("group", "samples");
}
@Test
void quartzJobDetailWhenNameDoesNotExistReturns404() {
ResponseEntity<String> response = this.restTemplate.getForEntity("/actuator/quartz/jobs/samples/does-not-exist",
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void quartzTriggerDetail() {
Map<String, Object> content = getContent("/actuator/quartz/triggers/samples/3am-weekdays");
assertThat(content).contains(entry("group", "samples"), entry("name", "3am-weekdays"), entry("state", "NORMAL"),
entry("type", "cron"));
}
@Test
void quartzTriggerDetailWhenNameDoesNotExistReturns404() {
ResponseEntity<String> response = this.restTemplate
.getForEntity("/actuator/quartz/triggers/samples/does-not-exist", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
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);
return entity.getBody();
}
@SuppressWarnings({ "unchecked", "rawtypes" })
private static <K, V> ResponseEntity<Map<K, V>> asMapEntity(ResponseEntity<Map> entity) {
return (ResponseEntity) entity;
}
@SuppressWarnings("rawtypes")
private static InstanceOfAssertFactory<Map, MapAssert<String, Object>> nestedMap() {
return InstanceOfAssertFactories.map(String.class, Object.class);
}
}
Loading…
Cancel
Save