Add execution metadata to scheduled tasks actuator endpoint

As of spring-projects/spring-framework#24560, Spring provides additional
metadata for scheduled tasks:
* next execution time
* last execution outcome (including status, time and raised exception)

This commit leverages this information to enhance the existing
`scheduledtasks` Actuator endpoint.

Closes gh-17585
This commit is contained in:
Brian Clozel
2024-07-25 09:26:50 +02:00
parent f1e98d0a73
commit e8391f121e
3 changed files with 237 additions and 74 deletions

View File

@@ -27,11 +27,13 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.restdocs.payload.FieldDescriptor;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.scheduling.Trigger;
import org.springframework.scheduling.TriggerContext;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler;
import org.springframework.scheduling.config.ScheduledTaskHolder;
import static org.assertj.core.api.Assertions.assertThat;
@@ -57,9 +59,12 @@ class ScheduledTasksEndpointDocumentationTests extends MockMvcEndpointDocumentat
"com.example.Processor")),
responseFields(fieldWithPath("cron").description("Cron tasks, if any."),
targetFieldWithPrefix("cron.[]."),
nextExecutionWithPrefix("cron.[].").description("Time of the next scheduled execution."),
fieldWithPath("cron.[].expression").description("Cron expression."),
fieldWithPath("fixedDelay").description("Fixed delay tasks, if any."),
targetFieldWithPrefix("fixedDelay.[]."), initialDelayWithPrefix("fixedDelay.[]."),
nextExecutionWithPrefix("fixedDelay.[].")
.description("Time of the next scheduled execution."),
fieldWithPath("fixedDelay.[].interval")
.description("Interval, in milliseconds, between the end of the last"
+ " execution and the start of the next."),
@@ -68,9 +73,15 @@ class ScheduledTasksEndpointDocumentationTests extends MockMvcEndpointDocumentat
fieldWithPath("fixedRate.[].interval")
.description("Interval, in milliseconds, between the start of each execution."),
initialDelayWithPrefix("fixedRate.[]."),
nextExecutionWithPrefix("fixedRate.[].")
.description("Time of the next scheduled execution."),
fieldWithPath("custom").description("Tasks with custom triggers, if any."),
targetFieldWithPrefix("custom.[]."),
fieldWithPath("custom.[].trigger").description("Trigger for the task."))));
fieldWithPath("custom.[].trigger").description("Trigger for the task."))
.andWithPrefix("*.[].",
fieldWithPath("lastExecution").description("Last execution of this task, if any.")
.optional())
.andWithPrefix("*.[].lastExecution.", lastExecution())));
}
private FieldDescriptor targetFieldWithPrefix(String prefix) {
@@ -81,6 +92,22 @@ class ScheduledTasksEndpointDocumentationTests extends MockMvcEndpointDocumentat
return fieldWithPath(prefix + "initialDelay").description("Delay, in milliseconds, before first execution.");
}
private FieldDescriptor nextExecutionWithPrefix(String prefix) {
return fieldWithPath(prefix + "nextExecution.time").description("Time of the next scheduled execution.");
}
private FieldDescriptor[] lastExecution() {
return new FieldDescriptor[] {
fieldWithPath("status").description("Status of the last execution (STARTED, SUCCESS, ERROR)."),
fieldWithPath("time").description("Time of the last execution.").type(JsonFieldType.STRING),
fieldWithPath("exception.type").description("Exception type thrown by the task, if any.")
.type(JsonFieldType.STRING)
.optional(),
fieldWithPath("exception.message").description("Message of the exception thrown by the task, if any.")
.type(JsonFieldType.STRING)
.optional() };
}
@Configuration(proxyBeanMethods = false)
@EnableScheduling
@Import(BaseDocumentationConfiguration.class)
@@ -96,7 +123,7 @@ class ScheduledTasksEndpointDocumentationTests extends MockMvcEndpointDocumentat
}
@Scheduled(fixedDelay = 5000, initialDelay = 5000)
@Scheduled(fixedDelay = 5000, initialDelay = 0)
void purge() {
}
@@ -108,7 +135,10 @@ class ScheduledTasksEndpointDocumentationTests extends MockMvcEndpointDocumentat
@Bean
SchedulingConfigurer schedulingConfigurer() {
return (registrar) -> registrar.addTriggerTask(new CustomTriggeredRunnable(), new CustomTrigger());
return (registrar) -> {
registrar.setTaskScheduler(new TestTaskScheduler());
registrar.addTriggerTask(new CustomTriggeredRunnable(), new CustomTrigger());
};
}
static class CustomTrigger implements Trigger {
@@ -124,7 +154,18 @@ class ScheduledTasksEndpointDocumentationTests extends MockMvcEndpointDocumentat
@Override
public void run() {
throw new IllegalStateException("Failed while running custom task");
}
}
static class TestTaskScheduler extends SimpleAsyncTaskScheduler {
TestTaskScheduler() {
setThreadNamePrefix("test-");
// do not log task errors
setErrorHandler((throwable) -> {
});
}
}

View File

@@ -17,14 +17,12 @@
package org.springframework.boot.actuate.scheduling;
import java.time.Duration;
import java.time.Instant;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.aot.hint.BindingReflectionHintsRegistrar;
import org.springframework.aot.hint.RuntimeHints;
@@ -42,15 +40,20 @@ import org.springframework.scheduling.config.IntervalTask;
import org.springframework.scheduling.config.ScheduledTask;
import org.springframework.scheduling.config.ScheduledTaskHolder;
import org.springframework.scheduling.config.Task;
import org.springframework.scheduling.config.TaskExecutionOutcome;
import org.springframework.scheduling.config.TaskExecutionOutcome.Status;
import org.springframework.scheduling.config.TriggerTask;
import org.springframework.scheduling.support.CronTrigger;
import org.springframework.scheduling.support.PeriodicTrigger;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
/**
* {@link Endpoint @Endpoint} to expose information about an application's scheduled
* tasks.
*
* @author Andy Wilkinson
* @author Brian Clozel
* @since 2.0.0
*/
@Endpoint(id = "scheduledtasks")
@@ -65,12 +68,16 @@ public class ScheduledTasksEndpoint {
@ReadOperation
public ScheduledTasksDescriptor scheduledTasks() {
Map<TaskType, List<TaskDescriptor>> descriptionsByType = this.scheduledTaskHolders.stream()
.flatMap((holder) -> holder.getScheduledTasks().stream())
.map(ScheduledTask::getTask)
.map(TaskDescriptor::of)
.filter(Objects::nonNull)
.collect(Collectors.groupingBy(TaskDescriptor::getType));
MultiValueMap<TaskType, TaskDescriptor> descriptionsByType = new LinkedMultiValueMap<>();
for (ScheduledTaskHolder holder : this.scheduledTaskHolders) {
for (ScheduledTask scheduledTask : holder.getScheduledTasks()) {
TaskType taskType = TaskType.forTask(scheduledTask);
if (taskType != null) {
TaskDescriptor descriptor = taskType.createDescriptor(scheduledTask);
descriptionsByType.add(descriptor.getType(), descriptor);
}
}
}
return new ScheduledTasksDescriptor(descriptionsByType);
}
@@ -117,45 +124,16 @@ public class ScheduledTasksEndpoint {
*/
public abstract static class TaskDescriptor {
private static final Map<Class<? extends Task>, Function<Task, TaskDescriptor>> DESCRIBERS = new LinkedHashMap<>();
static {
DESCRIBERS.put(FixedRateTask.class, (task) -> new FixedRateTaskDescriptor((FixedRateTask) task));
DESCRIBERS.put(FixedDelayTask.class, (task) -> new FixedDelayTaskDescriptor((FixedDelayTask) task));
DESCRIBERS.put(CronTask.class, (task) -> new CronTaskDescriptor((CronTask) task));
DESCRIBERS.put(TriggerTask.class, (task) -> describeTriggerTask((TriggerTask) task));
}
private final TaskType type;
private final ScheduledTask scheduledTask;
private final RunnableDescriptor runnable;
private static TaskDescriptor of(Task task) {
return DESCRIBERS.entrySet()
.stream()
.filter((entry) -> entry.getKey().isInstance(task))
.map((entry) -> entry.getValue().apply(task))
.findFirst()
.orElse(null);
}
private static TaskDescriptor describeTriggerTask(TriggerTask triggerTask) {
Trigger trigger = triggerTask.getTrigger();
if (trigger instanceof CronTrigger cronTrigger) {
return new CronTaskDescriptor(triggerTask, cronTrigger);
}
if (trigger instanceof PeriodicTrigger periodicTrigger) {
if (periodicTrigger.isFixedRate()) {
return new FixedRateTaskDescriptor(triggerTask, periodicTrigger);
}
return new FixedDelayTaskDescriptor(triggerTask, periodicTrigger);
}
return new CustomTriggerTaskDescriptor(triggerTask);
}
protected TaskDescriptor(TaskType type, Runnable runnable) {
protected TaskDescriptor(ScheduledTask scheduledTask, TaskType type) {
this.scheduledTask = scheduledTask;
this.type = type;
this.runnable = new RunnableDescriptor(runnable);
this.runnable = new RunnableDescriptor(scheduledTask.getTask().getRunnable());
}
private TaskType getType() {
@@ -166,6 +144,130 @@ public class ScheduledTasksEndpoint {
return this.runnable;
}
public final NextExecution getNextExecution() {
Instant nextExecution = this.scheduledTask.nextExecution();
if (nextExecution != null) {
return new NextExecution(nextExecution);
}
return null;
}
public final LastExecution getLastExecution() {
TaskExecutionOutcome lastExecutionOutcome = this.scheduledTask.getTask().getLastExecutionOutcome();
if (lastExecutionOutcome.status() != Status.NONE) {
return new LastExecution(lastExecutionOutcome);
}
return null;
}
}
public static final class NextExecution {
private final Instant time;
public NextExecution(Instant time) {
this.time = time;
}
public Instant getTime() {
return this.time;
}
}
public static final class LastExecution {
private final TaskExecutionOutcome lastExecutionOutcome;
private LastExecution(TaskExecutionOutcome lastExecutionOutcome) {
this.lastExecutionOutcome = lastExecutionOutcome;
}
public Status getStatus() {
return this.lastExecutionOutcome.status();
}
public Instant getTime() {
return this.lastExecutionOutcome.executionTime();
}
public ExceptionInfo getException() {
Throwable throwable = this.lastExecutionOutcome.throwable();
if (throwable != null) {
return new ExceptionInfo(throwable);
}
return null;
}
}
public static final class ExceptionInfo {
private final Throwable throwable;
private ExceptionInfo(Throwable throwable) {
this.throwable = throwable;
}
public String getType() {
return this.throwable.getClass().getName();
}
public String getMessage() {
return this.throwable.getMessage();
}
}
private enum TaskType {
CRON(CronTask.class,
(scheduledTask) -> new CronTaskDescriptor(scheduledTask, (CronTask) scheduledTask.getTask())),
FIXED_DELAY(FixedDelayTask.class,
(scheduledTask) -> new FixedDelayTaskDescriptor(scheduledTask,
(FixedDelayTask) scheduledTask.getTask())),
FIXED_RATE(FixedRateTask.class,
(scheduledTask) -> new FixedRateTaskDescriptor(scheduledTask, (FixedRateTask) scheduledTask.getTask())),
CUSTOM_TRIGGER(TriggerTask.class, TaskType::describeTriggerTask);
final Class<?> taskClass;
final Function<ScheduledTask, TaskDescriptor> describer;
TaskType(Class<?> taskClass, Function<ScheduledTask, TaskDescriptor> describer) {
this.taskClass = taskClass;
this.describer = describer;
}
static TaskType forTask(ScheduledTask scheduledTask) {
for (TaskType taskType : TaskType.values()) {
if (taskType.taskClass.isInstance(scheduledTask.getTask())) {
return taskType;
}
}
return null;
}
TaskDescriptor createDescriptor(ScheduledTask scheduledTask) {
return this.describer.apply(scheduledTask);
}
private static TaskDescriptor describeTriggerTask(ScheduledTask scheduledTask) {
TriggerTask triggerTask = (TriggerTask) scheduledTask.getTask();
Trigger trigger = triggerTask.getTrigger();
if (trigger instanceof CronTrigger cronTrigger) {
return new CronTaskDescriptor(scheduledTask, triggerTask, cronTrigger);
}
if (trigger instanceof PeriodicTrigger periodicTrigger) {
if (periodicTrigger.isFixedRate()) {
return new FixedRateTaskDescriptor(scheduledTask, triggerTask, periodicTrigger);
}
return new FixedDelayTaskDescriptor(scheduledTask, triggerTask, periodicTrigger);
}
return new CustomTriggerTaskDescriptor(scheduledTask);
}
}
/**
@@ -177,14 +279,15 @@ public class ScheduledTasksEndpoint {
private final long interval;
protected IntervalTaskDescriptor(TaskType type, IntervalTask task) {
super(type, task.getRunnable());
this.initialDelay = task.getInitialDelayDuration().toMillis();
this.interval = task.getIntervalDuration().toMillis();
protected IntervalTaskDescriptor(ScheduledTask scheduledTask, TaskType type, IntervalTask intervalTask) {
super(scheduledTask, type);
this.initialDelay = intervalTask.getInitialDelayDuration().toMillis();
this.interval = intervalTask.getIntervalDuration().toMillis();
}
protected IntervalTaskDescriptor(TaskType type, TriggerTask task, PeriodicTrigger trigger) {
super(type, task.getRunnable());
protected IntervalTaskDescriptor(ScheduledTask scheduledTask, TaskType type, TriggerTask task,
PeriodicTrigger trigger) {
super(scheduledTask, type);
Duration initialDelayDuration = trigger.getInitialDelayDuration();
this.initialDelay = (initialDelayDuration != null) ? initialDelayDuration.toMillis() : 0;
this.interval = trigger.getPeriodDuration().toMillis();
@@ -206,12 +309,12 @@ public class ScheduledTasksEndpoint {
*/
public static final class FixedDelayTaskDescriptor extends IntervalTaskDescriptor {
private FixedDelayTaskDescriptor(FixedDelayTask task) {
super(TaskType.FIXED_DELAY, task);
private FixedDelayTaskDescriptor(ScheduledTask scheduledTask, FixedDelayTask task) {
super(scheduledTask, TaskType.FIXED_DELAY, task);
}
private FixedDelayTaskDescriptor(TriggerTask task, PeriodicTrigger trigger) {
super(TaskType.FIXED_DELAY, task, trigger);
private FixedDelayTaskDescriptor(ScheduledTask scheduledTask, TriggerTask task, PeriodicTrigger trigger) {
super(scheduledTask, TaskType.FIXED_DELAY, task, trigger);
}
}
@@ -222,12 +325,12 @@ public class ScheduledTasksEndpoint {
*/
public static final class FixedRateTaskDescriptor extends IntervalTaskDescriptor {
private FixedRateTaskDescriptor(FixedRateTask task) {
super(TaskType.FIXED_RATE, task);
private FixedRateTaskDescriptor(ScheduledTask scheduledTask, FixedRateTask task) {
super(scheduledTask, TaskType.FIXED_RATE, task);
}
private FixedRateTaskDescriptor(TriggerTask task, PeriodicTrigger trigger) {
super(TaskType.FIXED_RATE, task, trigger);
private FixedRateTaskDescriptor(ScheduledTask scheduledTask, TriggerTask task, PeriodicTrigger trigger) {
super(scheduledTask, TaskType.FIXED_RATE, task, trigger);
}
}
@@ -240,13 +343,13 @@ public class ScheduledTasksEndpoint {
private final String expression;
private CronTaskDescriptor(CronTask task) {
super(TaskType.CRON, task.getRunnable());
this.expression = task.getExpression();
private CronTaskDescriptor(ScheduledTask scheduledTask, CronTask cronTask) {
super(scheduledTask, TaskType.CRON);
this.expression = cronTask.getExpression();
}
private CronTaskDescriptor(TriggerTask task, CronTrigger trigger) {
super(TaskType.CRON, task.getRunnable());
private CronTaskDescriptor(ScheduledTask scheduledTask, TriggerTask triggerTask, CronTrigger trigger) {
super(scheduledTask, TaskType.CRON);
this.expression = trigger.getExpression();
}
@@ -263,9 +366,10 @@ public class ScheduledTasksEndpoint {
private final String trigger;
private CustomTriggerTaskDescriptor(TriggerTask task) {
super(TaskType.CUSTOM_TRIGGER, task.getRunnable());
this.trigger = task.getTrigger().toString();
private CustomTriggerTaskDescriptor(ScheduledTask scheduledTask) {
super(scheduledTask, TaskType.CUSTOM_TRIGGER);
TriggerTask triggerTask = (TriggerTask) scheduledTask.getTask();
this.trigger = triggerTask.getTrigger().toString();
}
public String getTrigger() {
@@ -291,12 +395,6 @@ public class ScheduledTasksEndpoint {
}
private enum TaskType {
CRON, CUSTOM_TRIGGER, FIXED_DELAY, FIXED_RATE
}
static class ScheduledTasksEndpointRuntimeHints implements RuntimeHintsRegistrar {
private final BindingReflectionHintsRegistrar bindingRegistrar = new BindingReflectionHintsRegistrar();

View File

@@ -31,8 +31,10 @@ import org.springframework.boot.actuate.scheduling.ScheduledTasksEndpoint.CronTa
import org.springframework.boot.actuate.scheduling.ScheduledTasksEndpoint.CustomTriggerTaskDescriptor;
import org.springframework.boot.actuate.scheduling.ScheduledTasksEndpoint.FixedDelayTaskDescriptor;
import org.springframework.boot.actuate.scheduling.ScheduledTasksEndpoint.FixedRateTaskDescriptor;
import org.springframework.boot.actuate.scheduling.ScheduledTasksEndpoint.LastExecution;
import org.springframework.boot.actuate.scheduling.ScheduledTasksEndpoint.ScheduledTasksDescriptor;
import org.springframework.boot.actuate.scheduling.ScheduledTasksEndpoint.ScheduledTasksEndpointRuntimeHints;
import org.springframework.boot.actuate.scheduling.ScheduledTasksEndpoint.TaskDescriptor;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -42,6 +44,7 @@ import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.config.ScheduledTaskHolder;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
import org.springframework.scheduling.config.TaskExecutionOutcome.Status;
import org.springframework.scheduling.support.CronTrigger;
import org.springframework.scheduling.support.PeriodicTrigger;
@@ -52,6 +55,7 @@ import static org.assertj.core.api.Assertions.assertThat;
*
* @author Andy Wilkinson
* @author Moritz Halbritter
* @author Brian Clozel
*/
class ScheduledTasksEndpointTests {
@@ -68,6 +72,8 @@ class ScheduledTasksEndpointTests {
CronTaskDescriptor description = (CronTaskDescriptor) tasks.getCron().get(0);
assertThat(description.getExpression()).isEqualTo("0 0 0/3 1/1 * ?");
assertThat(description.getRunnable().getTarget()).isEqualTo(CronScheduledMethod.class.getName() + ".cron");
assertThat(description.getNextExecution().getTime()).isInTheFuture();
assertThat(description.getLastExecution()).isNull();
});
}
@@ -81,6 +87,7 @@ class ScheduledTasksEndpointTests {
CronTaskDescriptor description = (CronTaskDescriptor) tasks.getCron().get(0);
assertThat(description.getExpression()).isEqualTo("0 0 0/6 1/1 * ?");
assertThat(description.getRunnable().getTarget()).contains(CronTriggerRunnable.class.getName());
assertThat(description.getLastExecution()).isNull();
});
}
@@ -96,6 +103,7 @@ class ScheduledTasksEndpointTests {
assertThat(description.getInterval()).isOne();
assertThat(description.getRunnable().getTarget())
.isEqualTo(FixedDelayScheduledMethod.class.getName() + ".fixedDelay");
assertThat(description.getLastExecution()).isNull();
});
}
@@ -110,6 +118,7 @@ class ScheduledTasksEndpointTests {
assertThat(description.getInitialDelay()).isEqualTo(2000);
assertThat(description.getInterval()).isEqualTo(1000);
assertThat(description.getRunnable().getTarget()).contains(FixedDelayTriggerRunnable.class.getName());
assertThat(description.getLastExecution()).isNull();
});
}
@@ -124,6 +133,7 @@ class ScheduledTasksEndpointTests {
assertThat(description.getInitialDelay()).isEqualTo(0);
assertThat(description.getInterval()).isEqualTo(1000);
assertThat(description.getRunnable().getTarget()).contains(FixedDelayTriggerRunnable.class.getName());
assertThatTaskMayHaveBeenExecuted(description);
});
}
@@ -139,6 +149,7 @@ class ScheduledTasksEndpointTests {
assertThat(description.getInterval()).isEqualTo(3);
assertThat(description.getRunnable().getTarget())
.isEqualTo(FixedRateScheduledMethod.class.getName() + ".fixedRate");
assertThat(description.getLastExecution()).isNull();
});
}
@@ -153,6 +164,7 @@ class ScheduledTasksEndpointTests {
assertThat(description.getInitialDelay()).isEqualTo(3000);
assertThat(description.getInterval()).isEqualTo(2000);
assertThat(description.getRunnable().getTarget()).contains(FixedRateTriggerRunnable.class.getName());
assertThat(description.getLastExecution()).isNull();
});
}
@@ -167,6 +179,7 @@ class ScheduledTasksEndpointTests {
assertThat(description.getInitialDelay()).isEqualTo(0);
assertThat(description.getInterval()).isEqualTo(2000);
assertThat(description.getRunnable().getTarget()).contains(FixedRateTriggerRunnable.class.getName());
assertThatTaskMayHaveBeenExecuted(description);
});
}
@@ -180,6 +193,7 @@ class ScheduledTasksEndpointTests {
CustomTriggerTaskDescriptor description = (CustomTriggerTaskDescriptor) tasks.getCustom().get(0);
assertThat(description.getRunnable().getTarget()).contains(CustomTriggerRunnable.class.getName());
assertThat(description.getTrigger()).isEqualTo(CustomTriggerTask.trigger.toString());
assertThatTaskMayHaveBeenExecuted(description);
});
}
@@ -197,6 +211,16 @@ class ScheduledTasksEndpointTests {
}
}
private void assertThatTaskMayHaveBeenExecuted(TaskDescriptor descriptor) {
LastExecution lastExecution = descriptor.getLastExecution();
if (lastExecution != null) {
if (lastExecution.getStatus() == Status.SUCCESS) {
assertThat(lastExecution.getTime()).isInThePast();
assertThat(lastExecution.getException()).isNull();
}
}
}
private void run(Class<?> configuration, Consumer<ScheduledTasksDescriptor> consumer) {
this.contextRunner.withUserConfiguration(configuration)
.run((context) -> consumer.accept(context.getBean(ScheduledTasksEndpoint.class).scheduledTasks()));