Skip to content

Commit

Permalink
Add execution context in Quartz job detail
Browse files Browse the repository at this point in the history
This commit improves the Quartz Actuator endpoint to specify whether a
particular job is running. For a job that's running, the related trigger
has some details such as the fire time, whether it's recovering and the
re-fire count.

To make things more consistent, the detail of a trigger has now an
executions that contains detail about the previous, current, and next
executions.

Closes spring-projectsgh-43226
  • Loading branch information
snicoll committed Nov 20, 2024
1 parent d9458ac commit 3696ffe
Show file tree
Hide file tree
Showing 4 changed files with 216 additions and 23 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@ The resulting response is similar to the following:
include::partial$rest/actuator/quartz/job-details/http-response.adoc[]

If a key in the data map is identified as sensitive, its value is sanitized.
This job is not currently running. When a job is running, the related trigger has an additional `current` detail, as shown in the following example:

include::partial$rest/actuator/quartz/job-details-running/http-response.adoc[]



Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import org.quartz.Job;
import org.quartz.JobBuilder;
import org.quartz.JobDetail;
import org.quartz.JobExecutionContext;
import org.quartz.JobKey;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
Expand All @@ -59,6 +60,7 @@
import org.springframework.context.annotation.Import;
import org.springframework.restdocs.payload.FieldDescriptor;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.restdocs.payload.ResponseFieldsSnippet;
import org.springframework.scheduling.quartz.DelegatingJob;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.util.LinkedMultiValueMap;
Expand Down Expand Up @@ -276,19 +278,66 @@ void quartzJob() throws Exception {
given(this.scheduler.getTriggersOfJob(jobOne.getKey()))
.willAnswer((invocation) -> List.of(firstTrigger, secondTrigger));
assertThat(this.mvc.get().uri("/actuator/quartz/jobs/samples/jobOne")).hasStatusOk()
.apply(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 trigger group."),
fieldWithPath("triggers.[].name").description("Name of the trigger."),
previousFireTime("triggers.[]."), nextFireTime("triggers.[]."), priority("triggers.[]."))));
.apply(document("quartz/job-details", quartzJobDetail()));
}

@Test
void quartzJobRunning() throws Exception {
mockJobs(jobOne);
CronTrigger firstTrigger = cronTrigger.getTriggerBuilder().build();
setPreviousNextFireTime(firstTrigger, null, "2020-12-07T03:00:00Z");
SimpleTrigger secondTrigger = simpleTrigger.getTriggerBuilder().build();
setPreviousNextFireTime(secondTrigger, "2020-12-04T03:00:00Z", "2020-12-04T12:00:00Z");
mockTriggers(firstTrigger, secondTrigger);
JobExecutionContext jobExecutionContext = createJobExecutionContext(jobOne, simpleTrigger,
fromUtc("2020-12-04T12:00:12Z"));
given(this.scheduler.getCurrentlyExecutingJobs()).willReturn(List.of(jobExecutionContext));
given(this.scheduler.getTriggersOfJob(jobOne.getKey()))
.willAnswer((invocation) -> List.of(firstTrigger, secondTrigger));
assertThat(this.mvc.get().uri("/actuator/quartz/jobs/samples/jobOne")).hasStatusOk()
.apply(document("quartz/job-details-running", quartzJobDetail()));
}

private ResponseFieldsSnippet quartzJobDetail() {
return 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("running").description("Whether the job is currently running."),
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."),
priority("triggers.[]."),
fieldWithPath("triggers.[].executions").description("Details about the executions of the job."),
fieldWithPath("triggers.[].executions.previous")
.description("Details about the previous execution of the trigger."),
fieldWithPath("triggers.[].executions.previous.fireTime")
.description("Last time the trigger fired, if any.")
.optional(),
fieldWithPath("triggers.[].executions.current")
.description("Details about the current executions of the trigger, if any.")
.optional()
.type(JsonFieldType.OBJECT),
fieldWithPath("triggers.[].executions.current.fireTime")
.description("Time the trigger fired for the current execution.")
.type(JsonFieldType.STRING),
fieldWithPath("triggers.[].executions.current.recovering")
.description("Whether this execution is recovering.")
.type(JsonFieldType.BOOLEAN),
fieldWithPath("triggers.[].executions.current.refireCount")
.description("Number of times this execution was re-fired.")
.type(JsonFieldType.NUMBER),
fieldWithPath("triggers.[].executions.next")
.description("Details about the next execution of the trigger."),
fieldWithPath("triggers.[].executions.next.fireTime")
.description("Next time at which the Trigger is scheduled to fire, if any.")
.optional()
.type(JsonFieldType.STRING),
fieldWithPath("triggers.[].group").description("Name of the trigger group."),
fieldWithPath("triggers.[].name").description("Name of the trigger."),
previousFireTime("triggers.[].").ignored(), nextFireTime("triggers.[].").ignored());
}

@Test
Expand Down Expand Up @@ -416,6 +465,7 @@ private static FieldDescriptor previousFireTime(String prefix) {
private static FieldDescriptor nextFireTime(String prefix) {
return fieldWithPath(prefix + "nextFireTime").optional()
.type(JsonFieldType.STRING)
.ignored()
.description("Next time at which the Trigger is scheduled to fire, if any.");
}

Expand Down Expand Up @@ -477,6 +527,16 @@ private <T extends Trigger> void setPreviousNextFireTime(T trigger, String previ
}
}

private JobExecutionContext createJobExecutionContext(JobDetail jobDetail, Trigger trigger, Date fireTime) {
JobExecutionContext jobExecutionContext = mock(JobExecutionContext.class);
given(jobExecutionContext.getJobDetail()).willReturn(jobDetail);
given(jobExecutionContext.getTrigger()).willReturn(trigger);
given(jobExecutionContext.getFireTime()).willReturn(fireTime);
given(jobExecutionContext.isRecovering()).willReturn(false);
given(jobExecutionContext.getRefireCount()).willReturn(0);
return jobExecutionContext;
}

private static Date fromUtc(String utcTime) {
return Date.from(Instant.parse(utcTime));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
Expand All @@ -37,6 +38,7 @@
import org.quartz.Job;
import org.quartz.JobDataMap;
import org.quartz.JobDetail;
import org.quartz.JobExecutionContext;
import org.quartz.JobKey;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
Expand Down Expand Up @@ -203,29 +205,69 @@ public QuartzJobDetailsDescriptor quartzJob(String groupName, String jobName, bo
JobKey jobKey = JobKey.jobKey(jobName, groupName);
JobDetail jobDetail = this.scheduler.getJobDetail(jobKey);
if (jobDetail != null) {
List<JobExecutionContext> currentJobExecutions = getCurrentJobExecution(jobDetail);
List<? extends Trigger> triggers = this.scheduler.getTriggersOfJob(jobKey);
return new QuartzJobDetailsDescriptor(jobDetail.getKey().getGroup(), jobDetail.getKey().getName(),
jobDetail.getDescription(), jobDetail.getJobClass().getName(), jobDetail.isDurable(),
jobDetail.requestsRecovery(), sanitizeJobDataMap(jobDetail.getJobDataMap(), showUnsanitized),
extractTriggersSummary(triggers));
jobDetail.getDescription(), jobDetail.getJobClass().getName(), !currentJobExecutions.isEmpty(),
jobDetail.isDurable(), jobDetail.requestsRecovery(),
sanitizeJobDataMap(jobDetail.getJobDataMap(), showUnsanitized),
extractTriggersSummary(triggers, currentJobExecutions));
}
return null;
}

private static List<Map<String, Object>> extractTriggersSummary(List<? extends Trigger> triggers) {
private List<JobExecutionContext> getCurrentJobExecution(JobDetail jobDetail) throws SchedulerException {
return this.scheduler.getCurrentlyExecutingJobs()
.stream()
.filter((candidate) -> candidate.getJobDetail().getKey().equals(jobDetail.getKey()))
.toList();
}

private static List<Map<String, Object>> extractTriggersSummary(List<? extends Trigger> triggers,
List<JobExecutionContext> currentJobExecutions) {
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));
triggerSummary.put("priority", trigger.getPriority());
// deprecated as previousFireTime and nextFireTime have moved to executions
if (trigger.getPreviousFireTime() != null) {
triggerSummary.put("previousFireTime", trigger.getPreviousFireTime());
}
if (trigger.getNextFireTime() != null) {
triggerSummary.put("nextFireTime", trigger.getNextFireTime());
}

Map<String, Object> executions = new LinkedHashMap<>();
executions.put("previous", createExecutionSummary(trigger.getPreviousFireTime()));
JobExecutionContext jobExecutionContext = currentJobExecutions.stream()
.filter((candidate) -> candidate.getTrigger().getKey().equals(trigger.getKey()))
.findAny()
.orElse(null);
if (jobExecutionContext != null) {
Map<String, Object> executionSummary = createExecutionSummary(jobExecutionContext.getFireTime());
executionSummary.put("recovering", jobExecutionContext.isRecovering());
executionSummary.put("refireCount", jobExecutionContext.getRefireCount());
executions.put("current", executionSummary);
}
executions.put("next", createExecutionSummary(trigger.getNextFireTime()));
triggerSummary.put("executions", executions);
result.add(triggerSummary);
});
return result;
}

private static Map<String, Object> createExecutionSummary(Date fireTime) {
Map<String, Object> summary = new LinkedHashMap<>();
if (fireTime != null) {
summary.put("fireTime", fireTime);
}
return summary;
}

/**
* Return the details of the trigger identified by the given group name and trigger
* name.
Expand Down Expand Up @@ -400,6 +442,8 @@ public static final class QuartzJobDetailsDescriptor implements OperationRespons

private final String className;

private final boolean running;

private final boolean durable;

private final boolean requestRecovery;
Expand All @@ -408,12 +452,14 @@ public static final class QuartzJobDetailsDescriptor implements OperationRespons

private final List<Map<String, Object>> triggers;

QuartzJobDetailsDescriptor(String group, String name, String description, String className, boolean durable,
boolean requestRecovery, Map<String, Object> data, List<Map<String, Object>> triggers) {
QuartzJobDetailsDescriptor(String group, String name, String description, String className, boolean running,
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.running = running;
this.durable = durable;
this.requestRecovery = requestRecovery;
this.data = data;
Expand All @@ -436,6 +482,10 @@ public String getClassName() {
return this.className;
}

public boolean isRunning() {
return this.running;
}

public boolean isDurable() {
return this.durable;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -49,6 +49,7 @@
import org.quartz.Job;
import org.quartz.JobBuilder;
import org.quartz.JobDetail;
import org.quartz.JobExecutionContext;
import org.quartz.JobKey;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
Expand Down Expand Up @@ -650,6 +651,32 @@ void quartzJobWithoutTrigger() throws SchedulerException {
assertThat(jobDetails.getTriggers()).isEmpty();
}

@Test
@Deprecated(since = "3.5.0", forRemoval = true)
void quartzJobWithTriggerHasLegacyFireTimes() 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.isRunning()).isFalse();
assertThat(jobDetails.getTriggers()).hasSize(1);
Map<String, Object> triggerDetails = jobDetails.getTriggers().get(0);
assertThat(triggerDetails).contains(entry("previousFireTime", previousFireTime),
entry("nextFireTime", nextFireTime));
}

@Test
void quartzJobWithTrigger() throws SchedulerException {
Date previousFireTime = Date.from(Instant.parse("2020-11-30T03:00:00Z"));
Expand All @@ -668,10 +695,53 @@ void quartzJobWithTrigger() throws SchedulerException {
given(this.scheduler.getTriggersOfJob(JobKey.jobKey("hello", "samples")))
.willAnswer((invocation) -> Collections.singletonList(trigger));
QuartzJobDetailsDescriptor jobDetails = this.endpoint.quartzJob("samples", "hello", true);
assertThat(jobDetails.isRunning()).isFalse();
assertThat(jobDetails.getTriggers()).hasSize(1);
Map<String, Object> triggerDetails = jobDetails.getTriggers().get(0);
assertThat(triggerDetails).contains(entry("group", "samples"), entry("name", "3am-every-day"),
entry("priority", 4));
assertThat(triggerDetails).extractingByKey("executions", nestedMap()).satisfies((executions) -> {
assertThat(executions).containsOnlyKeys("previous", "next");
assertThat(executions).extractingByKey("previous", nestedMap())
.containsOnly(entry("fireTime", previousFireTime));
assertThat(executions).extractingByKey("next", nestedMap()).containsOnly(entry("fireTime", nextFireTime));
});
}

@Test
void quartzJobWithExecutingTrigger() 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);
Date fireTime = Date.from(Instant.parse("2020-12-01T03:00:00Z"));
JobExecutionContext jobExecutionContext = createJobExecutionContext(job, trigger, fireTime);
given(this.scheduler.getCurrentlyExecutingJobs()).willReturn(List.of(jobExecutionContext));
given(this.scheduler.getTriggersOfJob(JobKey.jobKey("hello", "samples")))
.willAnswer((invocation) -> Collections.singletonList(trigger));
QuartzJobDetailsDescriptor jobDetails = this.endpoint.quartzJob("samples", "hello", true);
assertThat(jobDetails.isRunning()).isTrue();
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));
assertThat(triggerDetails).contains(entry("group", "samples"), entry("name", "3am-every-day"),
entry("priority", 4));
assertThat(triggerDetails).extractingByKey("executions", nestedMap()).satisfies((executions) -> {
assertThat(executions).containsOnlyKeys("previous", "current", "next");
assertThat(executions).extractingByKey("previous", nestedMap())
.containsOnly(entry("fireTime", previousFireTime));
assertThat(executions).extractingByKey("current", nestedMap())
.containsOnly(entry("fireTime", fireTime), entry("recovering", false), entry("refireCount", 0));
assertThat(executions).extractingByKey("next", nestedMap()).containsOnly(entry("fireTime", nextFireTime));
});
}

@Test
Expand Down Expand Up @@ -783,6 +853,16 @@ private void mockTriggers(Trigger... triggers) throws SchedulerException {
}
}

private JobExecutionContext createJobExecutionContext(JobDetail jobDetail, Trigger trigger, Date fireTime) {
JobExecutionContext jobExecutionContext = mock(JobExecutionContext.class);
given(jobExecutionContext.getJobDetail()).willReturn(jobDetail);
given(jobExecutionContext.getTrigger()).willReturn(trigger);
given(jobExecutionContext.getFireTime()).willReturn(fireTime);
given(jobExecutionContext.isRecovering()).willReturn(false);
given(jobExecutionContext.getRefireCount()).willReturn(0);
return jobExecutionContext;
}

@SuppressWarnings("rawtypes")
private static InstanceOfAssertFactory<Map, MapAssert<String, Object>> nestedMap() {
return InstanceOfAssertFactories.map(String.class, Object.class);
Expand Down

0 comments on commit 3696ffe

Please sign in to comment.