Move code from spring-boot-actuator to spring-boot-quartz

This commit is contained in:
Andy Wilkinson
2025-05-12 12:28:54 +01:00
committed by Phillip Webb
parent 81771ecb24
commit aea09106f2
12 changed files with 40 additions and 37 deletions

View File

@@ -0,0 +1,862 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* 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.quartz.actuate.endpoint;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalTime;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalUnit;
import java.util.ArrayList;
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 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.quartz.utils.Key;
import org.springframework.boot.actuate.endpoint.OperationResponseBody;
import org.springframework.boot.actuate.endpoint.SanitizableData;
import org.springframework.boot.actuate.endpoint.Sanitizer;
import org.springframework.boot.actuate.endpoint.SanitizingFunction;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.util.Assert;
/**
* {@link Endpoint} to expose Quartz Scheduler jobs and triggers.
*
* @author Vedran Pavic
* @author Stephane Nicoll
* @since 4.0.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;
private final Sanitizer sanitizer;
public QuartzEndpoint(Scheduler scheduler, Iterable<SanitizingFunction> sanitizingFunctions) {
Assert.notNull(scheduler, "'scheduler' must not be null");
this.scheduler = scheduler;
this.sanitizer = new Sanitizer(sanitizingFunctions);
}
/**
* 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 QuartzDescriptor quartzReport() throws SchedulerException {
return new QuartzDescriptor(new GroupNamesDescriptor(this.scheduler.getJobGroupNames()),
new GroupNamesDescriptor(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 QuartzGroupsDescriptor quartzJobGroups() throws SchedulerException {
Map<String, Object> result = new LinkedHashMap<>();
for (String groupName : this.scheduler.getJobGroupNames()) {
List<String> jobs = this.scheduler.getJobKeys(GroupMatcher.jobGroupEquals(groupName))
.stream()
.map(Key::getName)
.toList();
result.put(groupName, Collections.singletonMap("jobs", jobs));
}
return new QuartzGroupsDescriptor(result);
}
/**
* 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 QuartzGroupsDescriptor 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::getName)
.toList());
result.put(groupName, groupDetails);
}
return new QuartzGroupsDescriptor(result);
}
/**
* 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 QuartzJobGroupSummaryDescriptor quartzJobGroupSummary(String group) throws SchedulerException {
List<JobDetail> jobs = findJobsByGroup(group);
if (jobs.isEmpty() && !this.scheduler.getJobGroupNames().contains(group)) {
return null;
}
Map<String, QuartzJobSummaryDescriptor> result = new LinkedHashMap<>();
for (JobDetail job : jobs) {
result.put(job.getKey().getName(), QuartzJobSummaryDescriptor.of(job));
}
return new QuartzJobGroupSummaryDescriptor(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 QuartzTriggerGroupSummaryDescriptor 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) -> {
TriggerDescriptor triggerDescriptor = TriggerDescriptor.of(trigger);
Map<String, Object> triggerTypes = result.computeIfAbsent(triggerDescriptor.getType(),
(key) -> new LinkedHashMap<>());
triggerTypes.put(trigger.getKey().getName(), triggerDescriptor.buildSummary(true));
});
boolean paused = this.scheduler.getPausedTriggerGroups().contains(group);
return new QuartzTriggerGroupSummaryDescriptor(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 QuartzJobDetailsDescriptor 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
* @param showUnsanitized whether to sanitize values in data map
* @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 QuartzJobDetailsDescriptor quartzJob(String groupName, String jobName, boolean showUnsanitized)
throws SchedulerException {
JobKey jobKey = JobKey.jobKey(jobName, groupName);
JobDetail jobDetail = this.scheduler.getJobDetail(jobKey);
if (jobDetail == null) {
return null;
}
List<? extends Trigger> triggers = this.scheduler.getTriggersOfJob(jobKey);
return new QuartzJobDetailsDescriptor(jobDetail, sanitizeJobDataMap(jobDetail.getJobDataMap(), showUnsanitized),
extractTriggersSummary(triggers));
}
/**
* Triggers (execute it now) a Quartz job by its group and job name.
* @param groupName the name of the job's group
* @param jobName the name of the job
* @return a description of the triggered job or {@code null} if the job does not
* exist
* @throws SchedulerException if there is an error triggering the job
* @since 3.5.0
*/
public QuartzJobTriggerDescriptor triggerQuartzJob(String groupName, String jobName) throws SchedulerException {
return triggerQuartzJob(JobKey.jobKey(jobName, groupName));
}
private QuartzJobTriggerDescriptor triggerQuartzJob(JobKey jobKey) throws SchedulerException {
JobDetail jobDetail = this.scheduler.getJobDetail(jobKey);
if (jobDetail == null) {
return null;
}
this.scheduler.triggerJob(jobKey);
return new QuartzJobTriggerDescriptor(jobDetail);
}
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(TriggerDescriptor.of(trigger).buildSummary(false));
result.add(triggerSummary);
});
return result;
}
/**
* 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
* @param showUnsanitized whether to sanitize values in data map
* @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
*/
Map<String, Object> quartzTrigger(String groupName, String triggerName, boolean showUnsanitized)
throws SchedulerException {
TriggerKey triggerKey = TriggerKey.triggerKey(triggerName, groupName);
Trigger trigger = this.scheduler.getTrigger(triggerKey);
if (trigger == null) {
return null;
}
TriggerState triggerState = this.scheduler.getTriggerState(triggerKey);
TriggerDescriptor triggerDescriptor = TriggerDescriptor.of(trigger);
Map<String, Object> jobDataMap = sanitizeJobDataMap(trigger.getJobDataMap(), showUnsanitized);
return OperationResponseBody.of(triggerDescriptor.buildDetails(triggerState, jobDataMap));
}
private static Duration getIntervalDuration(long amount, IntervalUnit unit) {
return temporalUnit(unit).getDuration().multipliedBy(amount);
}
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, boolean showUnsanitized) {
if (dataMap == null) {
return null;
}
Map<String, Object> map = new LinkedHashMap<>(dataMap.getWrappedMap());
map.replaceAll((key, value) -> getSanitizedValue(showUnsanitized, key, value));
return map;
}
private Object getSanitizedValue(boolean showUnsanitized, String key, Object value) {
SanitizableData data = new SanitizableData(null, key, value);
return this.sanitizer.sanitize(data, showUnsanitized);
}
private static TemporalUnit temporalUnit(IntervalUnit unit) {
return switch (unit) {
case DAY -> ChronoUnit.DAYS;
case HOUR -> ChronoUnit.HOURS;
case MINUTE -> ChronoUnit.MINUTES;
case MONTH -> ChronoUnit.MONTHS;
case SECOND -> ChronoUnit.SECONDS;
case MILLISECOND -> ChronoUnit.MILLIS;
case WEEK -> ChronoUnit.WEEKS;
case YEAR -> ChronoUnit.YEARS;
};
}
/**
* Description of available job and trigger group names.
*/
public static final class QuartzDescriptor implements OperationResponseBody {
private final GroupNamesDescriptor jobs;
private final GroupNamesDescriptor triggers;
QuartzDescriptor(GroupNamesDescriptor jobs, GroupNamesDescriptor triggers) {
this.jobs = jobs;
this.triggers = triggers;
}
public GroupNamesDescriptor getJobs() {
return this.jobs;
}
public GroupNamesDescriptor getTriggers() {
return this.triggers;
}
}
/**
* Description of group names.
*/
public static class GroupNamesDescriptor {
private final Set<String> groups;
public GroupNamesDescriptor(List<String> groups) {
this.groups = new LinkedHashSet<>(groups);
}
public Set<String> getGroups() {
return this.groups;
}
}
/**
* Description of each group identified by name.
*/
public static class QuartzGroupsDescriptor implements OperationResponseBody {
private final Map<String, Object> groups;
public QuartzGroupsDescriptor(Map<String, Object> groups) {
this.groups = groups;
}
public Map<String, Object> getGroups() {
return this.groups;
}
}
/**
* Description of the {@link JobDetail jobs} in a given group.
*/
public static final class QuartzJobGroupSummaryDescriptor implements OperationResponseBody {
private final String group;
private final Map<String, QuartzJobSummaryDescriptor> jobs;
QuartzJobGroupSummaryDescriptor(String group, Map<String, QuartzJobSummaryDescriptor> jobs) {
this.group = group;
this.jobs = jobs;
}
public String getGroup() {
return this.group;
}
public Map<String, QuartzJobSummaryDescriptor> getJobs() {
return this.jobs;
}
}
/**
* Description of a {@link Job Quartz Job}.
*/
public static final class QuartzJobSummaryDescriptor {
private final String className;
QuartzJobSummaryDescriptor(JobDetail job) {
this.className = job.getJobClass().getName();
}
private static QuartzJobSummaryDescriptor of(JobDetail job) {
return new QuartzJobSummaryDescriptor(job);
}
public String getClassName() {
return this.className;
}
}
/**
* Description of a triggered on-demand {@link Job Quartz Job}.
*
* @since 3.5.0
*/
public static final class QuartzJobTriggerDescriptor {
private final String group;
private final String name;
private final String className;
private final Instant triggerTime;
QuartzJobTriggerDescriptor(JobDetail jobDetail) {
this.group = jobDetail.getKey().getGroup();
this.name = jobDetail.getKey().getName();
this.className = jobDetail.getJobClass().getName();
this.triggerTime = Instant.now();
}
public String getGroup() {
return this.group;
}
public String getName() {
return this.name;
}
public String getClassName() {
return this.className;
}
public Instant getTriggerTime() {
return this.triggerTime;
}
}
/**
* Description of a {@link Job Quartz Job}.
*/
public static final class QuartzJobDetailsDescriptor implements OperationResponseBody {
private final String group;
private final String name;
private final String description;
private final String className;
private final boolean durable;
private final boolean requestRecovery;
private final Map<String, Object> data;
private final List<Map<String, Object>> triggers;
QuartzJobDetailsDescriptor(JobDetail jobDetail, Map<String, Object> data, List<Map<String, Object>> triggers) {
this.group = jobDetail.getKey().getGroup();
this.name = jobDetail.getKey().getName();
this.description = jobDetail.getDescription();
this.className = jobDetail.getJobClass().getName();
this.durable = jobDetail.isDurable();
this.requestRecovery = jobDetail.requestsRecovery();
this.data = data;
this.triggers = triggers;
}
public String getGroup() {
return this.group;
}
public String getName() {
return this.name;
}
public String getDescription() {
return this.description;
}
public String getClassName() {
return this.className;
}
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;
}
}
/**
* Description of the {@link Trigger triggers} in a given group.
*/
public static final class QuartzTriggerGroupSummaryDescriptor implements OperationResponseBody {
private final String group;
private final boolean paused;
private final Triggers triggers;
QuartzTriggerGroupSummaryDescriptor(String group, boolean paused,
Map<TriggerType, Map<String, Object>> descriptionsByType) {
this.group = group;
this.paused = paused;
this.triggers = new Triggers(descriptionsByType);
}
public String getGroup() {
return this.group;
}
public boolean isPaused() {
return this.paused;
}
public Triggers getTriggers() {
return this.triggers;
}
public static final class Triggers {
private final Map<String, Object> cron;
private final Map<String, Object> simple;
private final Map<String, Object> dailyTimeInterval;
private final Map<String, Object> calendarInterval;
private final Map<String, Object> custom;
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 Map<String, Object> getCron() {
return this.cron;
}
public Map<String, Object> getSimple() {
return this.simple;
}
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;
}
}
/**
* Base class for descriptions of a {@link Trigger}.
*/
public abstract static class TriggerDescriptor {
private static final Map<Class<? extends Trigger>, Function<Trigger, TriggerDescriptor>> DESCRIBERS;
static {
Map<Class<? extends Trigger>, Function<Trigger, TriggerDescriptor>> descriptors = new LinkedHashMap<>();
descriptors.put(CronTrigger.class, (trigger) -> new CronTriggerDescriptor((CronTrigger) trigger));
descriptors.put(SimpleTrigger.class, (trigger) -> new SimpleTriggerDescriptor((SimpleTrigger) trigger));
descriptors.put(DailyTimeIntervalTrigger.class,
(trigger) -> new DailyTimeIntervalTriggerDescriptor((DailyTimeIntervalTrigger) trigger));
descriptors.put(CalendarIntervalTrigger.class,
(trigger) -> new CalendarIntervalTriggerDescriptor((CalendarIntervalTrigger) trigger));
DESCRIBERS = Map.copyOf(descriptors);
}
private final Trigger trigger;
private final TriggerType type;
protected TriggerDescriptor(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;
}
static TriggerDescriptor of(Trigger trigger) {
return DESCRIBERS.entrySet()
.stream()
.filter((entry) -> entry.getKey().isInstance(trigger))
.map((entry) -> entry.getValue().apply(trigger))
.findFirst()
.orElse(new CustomTriggerDescriptor(trigger));
}
}
/**
* Description of a {@link CronTrigger}.
*/
public static final class CronTriggerDescriptor extends TriggerDescriptor {
private final CronTrigger trigger;
public CronTriggerDescriptor(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);
}
}
/**
* Description of a {@link SimpleTrigger}.
*/
public static final class SimpleTriggerDescriptor extends TriggerDescriptor {
private final SimpleTrigger trigger;
public SimpleTriggerDescriptor(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());
}
}
/**
* Description of a {@link DailyTimeIntervalTrigger}.
*/
public static final class DailyTimeIntervalTriggerDescriptor extends TriggerDescriptor {
private final DailyTimeIntervalTrigger trigger;
public DailyTimeIntervalTriggerDescriptor(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());
}
}
/**
* Description of a {@link CalendarIntervalTrigger}.
*/
public static final class CalendarIntervalTriggerDescriptor extends TriggerDescriptor {
private final CalendarIntervalTrigger trigger;
public CalendarIntervalTriggerDescriptor(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());
}
}
/**
* Description of a custom {@link Trigger}.
*/
public static final class CustomTriggerDescriptor extends TriggerDescriptor {
public CustomTriggerDescriptor(Trigger trigger) {
super(trigger, TriggerType.CUSTOM_TRIGGER);
}
@Override
protected void appendSummary(Map<String, Object> content) {
content.put("trigger", getTrigger().toString());
}
@Override
protected void appendDetails(Map<String, Object> content) {
appendSummary(content);
}
}
}

View File

@@ -0,0 +1,138 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* 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.quartz.actuate.endpoint;
import java.util.Set;
import org.quartz.SchedulerException;
import org.springframework.aot.hint.BindingReflectionHintsRegistrar;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.boot.actuate.endpoint.SecurityContext;
import org.springframework.boot.actuate.endpoint.Show;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.annotation.Selector;
import org.springframework.boot.actuate.endpoint.annotation.WriteOperation;
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExtension;
import org.springframework.boot.quartz.actuate.endpoint.QuartzEndpoint.QuartzGroupsDescriptor;
import org.springframework.boot.quartz.actuate.endpoint.QuartzEndpoint.QuartzJobDetailsDescriptor;
import org.springframework.boot.quartz.actuate.endpoint.QuartzEndpoint.QuartzJobGroupSummaryDescriptor;
import org.springframework.boot.quartz.actuate.endpoint.QuartzEndpoint.QuartzTriggerGroupSummaryDescriptor;
import org.springframework.boot.quartz.actuate.endpoint.QuartzEndpointWebExtension.QuartzEndpointWebExtensionRuntimeHints;
import org.springframework.context.annotation.ImportRuntimeHints;
/**
* {@link EndpointWebExtension @EndpointWebExtension} for the {@link QuartzEndpoint}.
*
* @author Stephane Nicoll
* @since 4.0.0
*/
@EndpointWebExtension(endpoint = QuartzEndpoint.class)
@ImportRuntimeHints(QuartzEndpointWebExtensionRuntimeHints.class)
public class QuartzEndpointWebExtension {
private final QuartzEndpoint delegate;
private final Show showValues;
private final Set<String> roles;
public QuartzEndpointWebExtension(QuartzEndpoint delegate, Show showValues, Set<String> roles) {
this.delegate = delegate;
this.showValues = showValues;
this.roles = roles;
}
@ReadOperation
public WebEndpointResponse<QuartzGroupsDescriptor> 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(SecurityContext securityContext,
@Selector String jobsOrTriggers, @Selector String group, @Selector String name) throws SchedulerException {
boolean showUnsanitized = this.showValues.isShown(securityContext, this.roles);
return handle(jobsOrTriggers, () -> this.delegate.quartzJob(group, name, showUnsanitized),
() -> this.delegate.quartzTrigger(group, name, showUnsanitized));
}
/**
* Trigger a Quartz job.
* @param jobs path segment "jobs"
* @param group job's group
* @param name job name
* @param state desired state
* @return web endpoint response
* @throws SchedulerException if there is an error triggering the job
* @since 3.5.0
*/
@WriteOperation
public WebEndpointResponse<Object> triggerQuartzJob(@Selector String jobs, @Selector String group,
@Selector String name, String state) throws SchedulerException {
if ("jobs".equals(jobs) && "running".equals(state)) {
return handleNull(this.delegate.triggerQuartzJob(group, name));
}
return new WebEndpointResponse<>(WebEndpointResponse.STATUS_BAD_REQUEST);
}
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) {
return (value != null) ? new WebEndpointResponse<>(value)
: new WebEndpointResponse<>(WebEndpointResponse.STATUS_NOT_FOUND);
}
@FunctionalInterface
private interface ResponseSupplier<T> {
T get() throws SchedulerException;
}
static class QuartzEndpointWebExtensionRuntimeHints implements RuntimeHintsRegistrar {
private final BindingReflectionHintsRegistrar bindingRegistrar = new BindingReflectionHintsRegistrar();
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
this.bindingRegistrar.registerReflectionHints(hints.reflection(), QuartzGroupsDescriptor.class,
QuartzJobDetailsDescriptor.class, QuartzJobGroupSummaryDescriptor.class,
QuartzTriggerGroupSummaryDescriptor.class);
}
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* 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.
*/
/**
* Actuator endpoint for Quartz Scheduler.
*/
package org.springframework.boot.quartz.actuate.endpoint;

View File

@@ -0,0 +1,820 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* 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.quartz.actuate.endpoint;
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.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.quartz.actuate.endpoint.QuartzEndpoint.QuartzDescriptor;
import org.springframework.boot.quartz.actuate.endpoint.QuartzEndpoint.QuartzJobDetailsDescriptor;
import org.springframework.boot.quartz.actuate.endpoint.QuartzEndpoint.QuartzJobGroupSummaryDescriptor;
import org.springframework.boot.quartz.actuate.endpoint.QuartzEndpoint.QuartzJobSummaryDescriptor;
import org.springframework.boot.quartz.actuate.endpoint.QuartzEndpoint.QuartzJobTriggerDescriptor;
import org.springframework.boot.quartz.actuate.endpoint.QuartzEndpoint.QuartzTriggerGroupSummaryDescriptor;
import org.springframework.scheduling.quartz.DelegatingJob;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.entry;
import static org.assertj.core.api.Assertions.within;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
/**
* Tests for {@link QuartzEndpoint}.
*
* @author Vedran Pavic
* @author Stephane Nicoll
*/
class QuartzEndpointTests {
private static final JobDetail jobOne = JobBuilder.newJob(Job.class).withIdentity("jobOne").build();
private static final JobDetail jobTwo = JobBuilder.newJob(DelegatingJob.class).withIdentity("jobTwo").build();
private static final JobDetail jobThree = JobBuilder.newJob(Job.class).withIdentity("jobThree", "samples").build();
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, Collections.emptyList());
}
@Test
void quartzReport() throws SchedulerException {
given(this.scheduler.getJobGroupNames()).willReturn(Arrays.asList("jobSamples", "DEFAULT"));
given(this.scheduler.getTriggerGroupNames()).willReturn(Collections.singletonList("triggerSamples"));
QuartzDescriptor quartzReport = this.endpoint.quartzReport();
assertThat(quartzReport.getJobs().getGroups()).containsOnly("jobSamples", "DEFAULT");
assertThat(quartzReport.getTriggers().getGroups()).containsOnly("triggerSamples");
then(this.scheduler).should().getJobGroupNames();
then(this.scheduler).should().getTriggerGroupNames();
then(this.scheduler).shouldHaveNoMoreInteractions();
}
@Test
void quartzReportWithNoJob() throws SchedulerException {
given(this.scheduler.getJobGroupNames()).willReturn(Collections.emptyList());
given(this.scheduler.getTriggerGroupNames()).willReturn(Arrays.asList("triggerSamples", "DEFAULT"));
QuartzDescriptor 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());
QuartzDescriptor 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"));
QuartzJobGroupSummaryDescriptor 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());
QuartzJobGroupSummaryDescriptor 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);
QuartzJobGroupSummaryDescriptor summary = this.endpoint.quartzJobGroupSummary("DEFAULT");
assertThat(summary).isNotNull();
assertThat(summary.getGroup()).isEqualTo("DEFAULT");
Map<String, QuartzJobSummaryDescriptor> 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"));
QuartzTriggerGroupSummaryDescriptor 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());
QuartzTriggerGroupSummaryDescriptor 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);
QuartzTriggerGroupSummaryDescriptor 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);
QuartzTriggerGroupSummaryDescriptor 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);
QuartzTriggerGroupSummaryDescriptor 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);
QuartzTriggerGroupSummaryDescriptor 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);
QuartzTriggerGroupSummaryDescriptor 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);
QuartzTriggerGroupSummaryDescriptor 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);
QuartzTriggerGroupSummaryDescriptor 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);
QuartzTriggerGroupSummaryDescriptor 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);
QuartzTriggerGroupSummaryDescriptor 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);
QuartzTriggerGroupSummaryDescriptor 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", true);
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", true);
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", true);
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", true);
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", true);
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", true);
assertThat(triggerDetails).extractingByKey("data", nestedMap())
.containsOnly(entry("user", "user"), entry("password", "secret"),
entry("url", "https://user:secret@example.com"));
}
@Test
void quartzTriggerWithDataMapAndShowUnsanitizedFalse() 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", false);
assertThat(triggerDetails).extractingByKey("data", nestedMap())
.containsOnly(entry("user", "******"), entry("password", "******"), entry("url", "******"));
}
@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", true);
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);
QuartzJobDetailsDescriptor jobDetails = this.endpoint.quartzJob("samples", "hello", true);
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));
QuartzJobDetailsDescriptor jobDetails = this.endpoint.quartzJob("samples", "hello", true);
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));
QuartzJobDetailsDescriptor jobDetails = this.endpoint.quartzJob("samples", "hello", true);
assertThat(jobDetails.getTriggers()).hasSize(2);
assertThat(jobDetails.getTriggers().get(0)).containsEntry("name", "two");
assertThat(jobDetails.getTriggers().get(1)).containsEntry("name", "one");
}
@Test
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));
QuartzJobDetailsDescriptor jobDetails = this.endpoint.quartzJob("samples", "hello", true);
assertThat(jobDetails.getTriggers()).hasSize(2);
assertThat(jobDetails.getTriggers().get(0)).containsEntry("name", "two");
assertThat(jobDetails.getTriggers().get(1)).containsEntry("name", "one");
}
@Test
void quartzJobWithDataMap() 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);
QuartzJobDetailsDescriptor jobDetails = this.endpoint.quartzJob("samples", "hello", true);
assertThat(jobDetails.getData()).containsOnly(entry("user", "user"), entry("password", "secret"),
entry("url", "https://user:secret@example.com"));
}
@Test
void quartzJobWithDataMapAndShowUnsanitizedFalse() 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);
QuartzJobDetailsDescriptor jobDetails = this.endpoint.quartzJob("samples", "hello", false);
assertThat(jobDetails.getData()).containsOnly(entry("user", "******"), entry("password", "******"),
entry("url", "******"));
}
@Test
void quartzJobShouldBeTriggered() throws SchedulerException {
JobDetail job = JobBuilder.newJob(Job.class)
.withIdentity("hello", "samples")
.withDescription("A sample job")
.storeDurably()
.requestRecovery(false)
.build();
mockJobs(job);
QuartzJobTriggerDescriptor quartzJobTriggerDescriptor = this.endpoint.triggerQuartzJob("samples", "hello");
assertThat(quartzJobTriggerDescriptor).isNotNull();
assertThat(quartzJobTriggerDescriptor.getName()).isEqualTo("hello");
assertThat(quartzJobTriggerDescriptor.getGroup()).isEqualTo("samples");
assertThat(quartzJobTriggerDescriptor.getClassName()).isEqualTo("org.quartz.Job");
assertThat(quartzJobTriggerDescriptor.getTriggerTime()).isCloseTo(Instant.now(), within(5, ChronoUnit.SECONDS));
then(this.scheduler).should().triggerJob(new JobKey("hello", "samples"));
}
@Test
void quartzJobShouldNotBeTriggeredWhenJobDoesNotExist() throws SchedulerException {
QuartzJobTriggerDescriptor quartzJobTriggerDescriptor = this.endpoint.triggerQuartzJob("samples", "hello");
assertThat(quartzJobTriggerDescriptor).isNull();
then(this.scheduler).should(never()).triggerJob(any());
}
private void mockJobs(JobDetail... jobs) throws SchedulerException {
MultiValueMap<String, JobKey> jobKeys = new LinkedMultiValueMap<>();
for (JobDetail jobDetail : jobs) {
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);
}
}

View File

@@ -0,0 +1,112 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* 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.quartz.actuate.endpoint;
import java.security.Principal;
import java.util.Collections;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.aot.hint.MemberCategory;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
import org.springframework.boot.actuate.endpoint.SecurityContext;
import org.springframework.boot.actuate.endpoint.Show;
import org.springframework.boot.quartz.actuate.endpoint.QuartzEndpoint.QuartzGroupsDescriptor;
import org.springframework.boot.quartz.actuate.endpoint.QuartzEndpoint.QuartzJobDetailsDescriptor;
import org.springframework.boot.quartz.actuate.endpoint.QuartzEndpoint.QuartzJobGroupSummaryDescriptor;
import org.springframework.boot.quartz.actuate.endpoint.QuartzEndpoint.QuartzTriggerGroupSummaryDescriptor;
import org.springframework.boot.quartz.actuate.endpoint.QuartzEndpointWebExtension.QuartzEndpointWebExtensionRuntimeHints;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link QuartzEndpointWebExtension}.
*
* @author Moritz Halbritter
* @author Madhura Bhave
*/
class QuartzEndpointWebExtensionTests {
private QuartzEndpointWebExtension webExtension;
private QuartzEndpoint delegate;
@BeforeEach
void setup() {
this.delegate = mock(QuartzEndpoint.class);
}
@Test
void whenShowValuesIsNever() throws Exception {
this.webExtension = new QuartzEndpointWebExtension(this.delegate, Show.NEVER, Collections.emptySet());
this.webExtension.quartzJobOrTrigger(null, "jobs", "a", "b");
this.webExtension.quartzJobOrTrigger(null, "triggers", "a", "b");
then(this.delegate).should().quartzJob("a", "b", false);
then(this.delegate).should().quartzTrigger("a", "b", false);
}
@Test
void whenShowValuesIsAlways() throws Exception {
this.webExtension = new QuartzEndpointWebExtension(this.delegate, Show.ALWAYS, Collections.emptySet());
this.webExtension.quartzJobOrTrigger(null, "a", "b", "c");
this.webExtension.quartzJobOrTrigger(null, "jobs", "a", "b");
this.webExtension.quartzJobOrTrigger(null, "triggers", "a", "b");
then(this.delegate).should().quartzJob("a", "b", true);
then(this.delegate).should().quartzTrigger("a", "b", true);
}
@Test
void whenShowValuesIsWhenAuthorizedAndSecurityContextIsAuthorized() throws Exception {
SecurityContext securityContext = mock(SecurityContext.class);
given(securityContext.getPrincipal()).willReturn(mock(Principal.class));
this.webExtension = new QuartzEndpointWebExtension(this.delegate, Show.WHEN_AUTHORIZED, Collections.emptySet());
this.webExtension.quartzJobOrTrigger(securityContext, "jobs", "a", "b");
this.webExtension.quartzJobOrTrigger(securityContext, "triggers", "a", "b");
then(this.delegate).should().quartzJob("a", "b", true);
then(this.delegate).should().quartzTrigger("a", "b", true);
}
@Test
void whenShowValuesIsWhenAuthorizedAndSecurityContextIsNotAuthorized() throws Exception {
SecurityContext securityContext = mock(SecurityContext.class);
this.webExtension = new QuartzEndpointWebExtension(this.delegate, Show.WHEN_AUTHORIZED, Collections.emptySet());
this.webExtension.quartzJobOrTrigger(securityContext, "jobs", "a", "b");
this.webExtension.quartzJobOrTrigger(securityContext, "triggers", "a", "b");
then(this.delegate).should().quartzJob("a", "b", false);
then(this.delegate).should().quartzTrigger("a", "b", false);
}
@Test
void shouldRegisterHints() {
RuntimeHints runtimeHints = new RuntimeHints();
new QuartzEndpointWebExtensionRuntimeHints().registerHints(runtimeHints, getClass().getClassLoader());
Set<Class<?>> bindingTypes = Set.of(QuartzGroupsDescriptor.class, QuartzJobDetailsDescriptor.class,
QuartzJobGroupSummaryDescriptor.class, QuartzTriggerGroupSummaryDescriptor.class);
for (Class<?> bindingType : bindingTypes) {
assertThat(RuntimeHintsPredicates.reflection()
.onType(bindingType)
.withMemberCategories(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS)).accepts(runtimeHints);
}
}
}

View File

@@ -0,0 +1,348 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* 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.quartz.actuate.endpoint;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import net.minidev.json.JSONArray;
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.Show;
import org.springframework.boot.actuate.endpoint.web.test.WebEndpointTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.scheduling.quartz.DelegatingJob;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.util.LinkedMultiValueMap;
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-else").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();
}
@WebEndpointTest
void quartzTriggerJob(WebTestClient client) {
client.post()
.uri("/actuator/quartz/jobs/samples/jobOne")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("state", "running"))
.exchange()
.expectStatus()
.isOk()
.expectBody()
.jsonPath("group")
.isEqualTo("samples")
.jsonPath("name")
.isEqualTo("jobOne")
.jsonPath("className")
.isEqualTo("org.quartz.Job")
.jsonPath("triggerTime")
.isNotEmpty();
}
@WebEndpointTest
void quartzTriggerJobWithUnknownJobKey(WebTestClient client) {
client.post()
.uri("/actuator/quartz/jobs/samples/does-not-exist")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("state", "running"))
.exchange()
.expectStatus()
.isNotFound();
}
@WebEndpointTest
void quartzTriggerJobWithUnknownState(WebTestClient client) {
client.post()
.uri("/actuator/quartz/jobs/samples/jobOne")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("state", "unknown"))
.exchange()
.expectStatus()
.isBadRequest();
}
@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, Collections.emptyList());
}
@Bean
QuartzEndpointWebExtension quartzEndpointWebExtension(QuartzEndpoint endpoint) {
return new QuartzEndpointWebExtension(endpoint, Show.ALWAYS, Collections.emptySet());
}
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()));
}
}
}
}