From 7990c0edbb7f0a12d6623bc97d80a4b676d3e8f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Nicoll?= Date: Wed, 20 Nov 2024 11:26:13 +0100 Subject: [PATCH] Add execution context in Quartz job detail 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 gh-43226 --- .../api/pages/rest/actuator/quartz.adoc | 3 + .../QuartzEndpointDocumentationTests.java | 86 ++++++++++++++++--- .../boot/actuate/quartz/QuartzEndpoint.java | 64 ++++++++++++-- .../actuate/quartz/QuartzEndpointTests.java | 86 ++++++++++++++++++- 4 files changed, 216 insertions(+), 23 deletions(-) diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/antora/modules/api/pages/rest/actuator/quartz.adoc b/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/antora/modules/api/pages/rest/actuator/quartz.adoc index 9bb74a5f26fe..bb76180cfcb4 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/antora/modules/api/pages/rest/actuator/quartz.adoc +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/antora/modules/api/pages/rest/actuator/quartz.adoc @@ -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[] diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/QuartzEndpointDocumentationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/QuartzEndpointDocumentationTests.java index 4f8a04060040..f05a09f426c6 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/QuartzEndpointDocumentationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/QuartzEndpointDocumentationTests.java @@ -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; @@ -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; @@ -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 @@ -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."); } @@ -477,6 +527,16 @@ private 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)); } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/QuartzEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/QuartzEndpoint.java index e6cdde6920d2..f4cb73f32bd3 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/QuartzEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/quartz/QuartzEndpoint.java @@ -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; @@ -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; @@ -203,16 +205,26 @@ public QuartzJobDetailsDescriptor quartzJob(String groupName, String jobName, bo JobKey jobKey = JobKey.jobKey(jobName, groupName); JobDetail jobDetail = this.scheduler.getJobDetail(jobKey); if (jobDetail != null) { + List currentJobExecutions = getCurrentJobExecution(jobDetail); List 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> extractTriggersSummary(List triggers) { + private List getCurrentJobExecution(JobDetail jobDetail) throws SchedulerException { + return this.scheduler.getCurrentlyExecutingJobs() + .stream() + .filter((candidate) -> candidate.getJobDetail().getKey().equals(jobDetail.getKey())) + .toList(); + } + + private static List> extractTriggersSummary(List triggers, + List currentJobExecutions) { List triggersToSort = new ArrayList<>(triggers); triggersToSort.sort(TRIGGER_COMPARATOR); List> result = new ArrayList<>(); @@ -220,12 +232,42 @@ private static List> extractTriggersSummary(List 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 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 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 createExecutionSummary(Date fireTime) { + Map 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. @@ -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; @@ -408,12 +452,14 @@ public static final class QuartzJobDetailsDescriptor implements OperationRespons private final List> triggers; - QuartzJobDetailsDescriptor(String group, String name, String description, String className, boolean durable, - boolean requestRecovery, Map data, List> triggers) { + QuartzJobDetailsDescriptor(String group, String name, String description, String className, boolean running, + boolean durable, boolean requestRecovery, Map data, + List> 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; @@ -436,6 +482,10 @@ public String getClassName() { return this.className; } + public boolean isRunning() { + return this.running; + } + public boolean isDurable() { return this.durable; } diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/quartz/QuartzEndpointTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/quartz/QuartzEndpointTests.java index 9fa95ed84ff0..098501b36de6 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/quartz/QuartzEndpointTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/quartz/QuartzEndpointTests.java @@ -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. @@ -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; @@ -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 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")); @@ -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 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 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 @@ -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> nestedMap() { return InstanceOfAssertFactories.map(String.class, Object.class);