Since Spring 6.2.0 functionality to specify custom scheduler for @Scheduled annotation is not working anymore.

Minimal reproducible example:

import static org.assertj.core.api.Assertions.assertThat;

import java.util.HashSet;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.scheduling.config.ScheduledTaskHolder;
import org.springframework.scheduling.config.TaskSchedulerRouter;

class SchedulerConfigTest {

    @Test
    void withQualifiedScheduler() throws Exception {
        var ctx = new AnnotationConfigApplicationContext(QualifiedExplicitSchedulerConfig.class);
        assertThat(ctx.getBean(ScheduledTaskHolder.class).getScheduledTasks()).hasSize(2);

        Thread.sleep(110);

        assertThat(ctx.getBean("defaultSchedulerThreads", Set.class))
            .hasSizeGreaterThanOrEqualTo(1).allMatch(e -> ((String) e).startsWith("taskScheduler-"));

        assertThat(ctx.getBean("explicitSchedulerThreads", Set.class))
            .hasSizeGreaterThanOrEqualTo(1).allMatch(e -> ((String) e).startsWith("customScheduler-"));
    }

    @TestConfiguration
    @EnableScheduling
    static class QualifiedExplicitSchedulerConfig {

        public static final String DEFAULT_TASK_SCHEDULER_BEAN_NAME = TaskSchedulerRouter.DEFAULT_TASK_SCHEDULER_BEAN_NAME;
        public static final String CUSTOM_TASK_SCHEDULER_BEAN_NAME = "customTaskScheduler";

        @Bean
        public Set<String> defaultSchedulerThreads() {
            return new HashSet<>();
        }

        @Bean
        public Set<String> explicitSchedulerThreads() {
            return new HashSet<>();
        }

        @Bean(name = DEFAULT_TASK_SCHEDULER_BEAN_NAME)
        public ThreadPoolTaskScheduler taskScheduler() {
            ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
            taskScheduler.setThreadNamePrefix("taskScheduler-");
            return taskScheduler;
        }

        @Bean(name = CUSTOM_TASK_SCHEDULER_BEAN_NAME)
        public SimpleAsyncTaskScheduler customTaskScheduler() {
            SimpleAsyncTaskScheduler taskScheduler = new SimpleAsyncTaskScheduler();
            taskScheduler.setThreadNamePrefix("customScheduler-");
            return taskScheduler;
        }

        @Scheduled(fixedRate = 10)
        public void task() throws Exception {
            defaultSchedulerThreads().add(Thread.currentThread().getName());
        }

        @Scheduled(fixedRate = 10, scheduler = CUSTOM_TASK_SCHEDULER_BEAN_NAME)
        public void taskWithExplicitScheduler() throws Exception {
            explicitSchedulerThreads().add(Thread.currentThread().getName());
        }

    }

}

I believe this happened in this commit dc2c8d60. Now Runnable is wrapped into OutcomeTrackingRunnable. As result TaskSchedulerRouter fails to determine qualifier:

protected String determineQualifier(Runnable task) {
     return (task instanceof SchedulingAwareRunnable sar ? sar.getQualifier() : null);
}

since OutcomeTrackingRunnable is not implementing SchedulingAwareRunnable.

@bclozel, do you think it makes sense for OutcomeTrackingRunnable to implement SchedulingAwareRunnable interface instead of Runnable? Or to have two flavours of OutcomeTrackingRunnable? And Task will wrap underlying runnable depends on underlying task?

Smth like:

public Task(Runnable runnable) {
    Assert.notNull(runnable, "Runnable must not be null");
    if (runnable instanceof SchedulingAwareRunnable sar) {
        this.runnable = new OutcomeTrackingSchedulingAwareRunnable(runnable);
    } else {
        this.runnable = new OutcomeTrackingRunnable(runnable);
    }
    this.lastExecutionOutcome = TaskExecutionOutcome.create();
}

Comment From: jhoeller

Looks sensible to me for OutcomeTrackingRunnable to implement SchedulingAwareRunnable and pass the isLongLived()/getQualifier() calls through to the underlying Runnable when possible or return the default values otherwise (which is technically equivalent to not implementing SchedulingAwareRunnable at all). Let's revise this for 6.2.1.