Skip to content

Commit 35052f2

Browse files
simonbaslebclozel
authored andcommitted
Support @Scheduled fixedDelay/fixedRate on Publisher-returning methods
This commit adds support for `@Scheduled` annotation on reactive methods and Kotlin suspending functions. Reactive methods are methods that return a `Publisher` or a subclass of `Publisher`. The `ReactiveAdapterRegistry` is used to support many implementations, such as `Flux`, `Mono`, `Flow`, `Single`, etc. Methods should not take any argument and published values will be ignored, as they are already with synchronous support. This is implemented in `ScheduledAnnotationReactiveSupport`, which "converts" Publishers to `Runnable`. This strategy keeps track of active Subscriptions in the `ScheduledAnnotationBeanPostProcessor`, in order to cancel them all in case of shutdown. The existing scheduling support for tasks is reused, aligning the triggering behavior with the existing support: cron, fixedDelay and fixedRate are all supported strategies. If the `Publisher` errors, the exception is logged at warn level and otherwise ignored. As a result new `Runnable` instances will be created for each execution and scheduling will continue. The only difference with synchronous support is that error signals will not be thrown by those `Runnable` tasks and will not be made available to the `org.springframework.util.ErrorHandler` contract. This is due to the asynchronous and lazy nature of Publishers. Closes gh-23533 Closes gh-28515
1 parent 53f8912 commit 35052f2

File tree

7 files changed

+877
-4
lines changed

7 files changed

+877
-4
lines changed

framework-docs/modules/ROOT/pages/integration/scheduling.adoc

+111
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,117 @@ container and once through the `@Configurable` aspect), with the consequence of
393393
`@Scheduled` method being invoked twice.
394394
====
395395

396+
[[scheduling-annotation-support-scheduled-reactive]]
397+
=== The `@Scheduled` annotation on Reactive methods or Kotlin suspending functions
398+
399+
As of Spring Framework 6.1, `@Scheduled` methods are also supported on several types
400+
of reactive methods:
401+
402+
- methods with a `Publisher` return type (or any concrete implementation of `Publisher`)
403+
like in the following example:
404+
405+
[source,java,indent=0,subs="verbatim,quotes"]
406+
----
407+
@Scheduled(fixedDelay = 500)
408+
public Publisher<Void> reactiveSomething() {
409+
// return an instance of Publisher
410+
}
411+
----
412+
413+
- methods with a return type that can be adapted to `Publisher` via the shared instance
414+
of the `ReactiveAdapterRegistry`, provided the type supports _deferred subscription_ like
415+
in the following example:
416+
417+
[source,java,indent=0,subs="verbatim,quotes"]
418+
----
419+
@Scheduled(fixedDelay = 500)
420+
public Single<String> rxjavaNonPublisher() {
421+
return Single.just("example");
422+
}
423+
----
424+
425+
[NOTE]
426+
====
427+
The `CompletableFuture` class is an example of a type that can typically be adapted
428+
to `Publisher` but doesn't support deferred subscription. Its `ReactiveAdapter` in the
429+
registry denotes that by having the `getDescriptor().isDeferred()` method return `false`.
430+
====
431+
432+
433+
- Kotlin suspending functions, like in the following example:
434+
435+
[source,kotlin,indent=0,subs="verbatim,quotes"]
436+
----
437+
@Scheduled(fixedDelay = 500)
438+
suspend fun something() {
439+
// do something asynchronous
440+
}
441+
----
442+
443+
- methods that return a Kotlin `Flow` or `Deferred` instance, like in the following example:
444+
445+
[source,kotlin,indent=0,subs="verbatim,quotes"]
446+
----
447+
@Scheduled(fixedDelay = 500)
448+
fun something(): Flow<Void> {
449+
flow {
450+
// do something asynchronous
451+
}
452+
}
453+
----
454+
455+
All these types of methods must be declared without any arguments. In the case of Kotlin
456+
suspending functions the `kotlinx.coroutines.reactor` bridge must also be present to allow
457+
the framework to invoke a suspending function as a `Publisher`.
458+
459+
The Spring Framework will obtain a `Publisher` out of the annotated method once and will
460+
schedule a `Runnable` in which it subscribes to said `Publisher`. These inner regular
461+
subscriptions happen according to the `cron`/fixedDelay`/`fixedRate` configuration.
462+
463+
If the `Publisher` emits `onNext` signal(s), these are ignored and discarded (the same way
464+
return values from synchronous `@Scheduled` methods are ignored).
465+
466+
In the following example, the `Flux` emits `onNext("Hello"), onNext("World")` every 5
467+
seconds, but these values are unused:
468+
469+
[source,java,indent=0,subs="verbatim,quotes"]
470+
----
471+
@Scheduled(initialDelay = 5000, fixedRate = 5000)
472+
public Flux<String> reactiveSomething() {
473+
return Flux.just("Hello", "World");
474+
}
475+
----
476+
477+
If the `Publisher` emits an `onError` signal, it is logged at WARN level and recovered.
478+
As a result, further scheduled subscription do happen despite the error.
479+
480+
In the following example, the `Mono` subscription fails twice in the first five seconds
481+
then subscriptions start succeeding, printing a message to the standard output every five
482+
seconds:
483+
484+
[source,java,indent=0,subs="verbatim,quotes"]
485+
----
486+
@Scheduled(initialDelay = 0, fixedRate = 5000)
487+
public Mono<Void> reactiveSomething() {
488+
AtomicInteger countdown = new AtomicInteger(2);
489+
490+
return Mono.defer(() -> {
491+
if (countDown.get() == 0 || countDown.decrementAndGet() == 0) {
492+
return Mono.fromRunnable(() -> System.out.println("Message"));
493+
}
494+
return Mono.error(new IllegalStateException("Cannot deliver message"));
495+
})
496+
}
497+
----
498+
499+
[NOTE]
500+
====
501+
When destroying the annotated bean or closing the application context Spring Framework cancels
502+
scheduled tasks, which includes the next scheduled subscription to the `Publisher` as well
503+
as any past subscription that is still currently active (e.g. for long-running publishers,
504+
or even infinite publishers).
505+
====
506+
396507

397508
[[scheduling-annotation-support-async]]
398509
=== The `@Async` annotation

spring-context/spring-context.gradle

+3
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ dependencies {
2727
optional("org.jetbrains.kotlin:kotlin-reflect")
2828
optional("org.jetbrains.kotlin:kotlin-stdlib")
2929
optional("org.reactivestreams:reactive-streams")
30+
optional("io.projectreactor:reactor-core")
3031
testImplementation(project(":spring-core-test"))
3132
testImplementation(testFixtures(project(":spring-aop")))
3233
testImplementation(testFixtures(project(":spring-beans")))
@@ -38,6 +39,8 @@ dependencies {
3839
testImplementation("org.awaitility:awaitility")
3940
testImplementation("jakarta.inject:jakarta.inject-tck")
4041
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")
42+
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
43+
testImplementation("io.reactivex.rxjava3:rxjava")
4144
testRuntimeOnly("jakarta.xml.bind:jakarta.xml.bind-api")
4245
testRuntimeOnly("org.glassfish:jakarta.el")
4346
// Substitute for javax.management:jmxremote_optional:1.0.1_04 (not available on Maven Central)

spring-context/src/main/java/org/springframework/scheduling/annotation/Scheduled.java

+14
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,20 @@
3636
* a {@code void} return type; if not, the returned value will be ignored
3737
* when called through the scheduler.
3838
*
39+
* <p>Methods that return a reactive {@code Publisher} or a type which can be adapted
40+
* to {@code Publisher} by the default {@code ReactiveAdapterRegistry} are supported.
41+
* The {@code Publisher} MUST support multiple subsequent subscriptions (i.e. be cold).
42+
* The returned Publisher is only produced once, and the scheduling infrastructure
43+
* then periodically {@code subscribe()} to it according to configuration.
44+
* Values emitted by the publisher are ignored. Errors are logged at WARN level, which
45+
* doesn't prevent further iterations. If a {@code fixed delay} is configured, the
46+
* subscription is blocked upon in order to respect the fixed delay semantics.
47+
*
48+
* <p>Kotlin suspending functions are also supported, provided the coroutine-reactor
49+
* bridge ({@code kotlinx.coroutine.reactor}) is present at runtime. This bridge is
50+
* used to adapt the suspending function into a {@code Publisher} which is treated
51+
* the same way as in the reactive method case (see above).
52+
*
3953
* <p>Processing of {@code @Scheduled} annotations is performed by
4054
* registering a {@link ScheduledAnnotationBeanPostProcessor}. This can be
4155
* done manually or, more conveniently, through the {@code <task:annotation-driven/>}

spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessor.java

+88-4
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import java.util.Set;
2929
import java.util.TimeZone;
3030
import java.util.concurrent.ConcurrentHashMap;
31+
import java.util.concurrent.CopyOnWriteArrayList;
3132
import java.util.concurrent.ScheduledExecutorService;
3233
import java.util.concurrent.TimeUnit;
3334

@@ -98,6 +99,7 @@
9899
* @author Elizabeth Chatman
99100
* @author Victor Brown
100101
* @author Sam Brannen
102+
* @author Simon Baslé
101103
* @since 3.0
102104
* @see Scheduled
103105
* @see EnableScheduling
@@ -143,6 +145,8 @@ public class ScheduledAnnotationBeanPostProcessor
143145

144146
private final Map<Object, Set<ScheduledTask>> scheduledTasks = new IdentityHashMap<>(16);
145147

148+
private final Map<Object, List<Runnable>> reactiveSubscriptions = new IdentityHashMap<>(16);
149+
146150

147151
/**
148152
* Create a default {@code ScheduledAnnotationBeanPostProcessor}.
@@ -385,15 +389,33 @@ public Object postProcessAfterInitialization(Object bean, String beanName) {
385389
}
386390

387391
/**
388-
* Process the given {@code @Scheduled} method declaration on the given bean.
392+
* Process the given {@code @Scheduled} method declaration on the given bean,
393+
* attempting to distinguish {@link #processScheduledAsync(Scheduled, Method, Object) reactive}
394+
* methods from {@link #processScheduledSync(Scheduled, Method, Object) synchronous} methods.
389395
* @param scheduled the {@code @Scheduled} annotation
390396
* @param method the method that the annotation has been declared on
391397
* @param bean the target bean instance
392-
* @see #createRunnable(Object, Method)
398+
* @see #processScheduledSync(Scheduled, Method, Object)
399+
* @see #processScheduledAsync(Scheduled, Method, Object)
393400
*/
394401
protected void processScheduled(Scheduled scheduled, Method method, Object bean) {
402+
// Is method a Kotlin suspending function? Throws if true but reactor bridge isn't on the classpath.
403+
// Is method returning a reactive type? Throws if true, but it isn't a deferred Publisher type.
404+
if (ScheduledAnnotationReactiveSupport.isReactive(method)) {
405+
processScheduledAsync(scheduled, method, bean);
406+
return;
407+
}
408+
processScheduledSync(scheduled, method, bean);
409+
}
410+
411+
/**
412+
* Parse the {@code Scheduled} annotation and schedule the provided {@code Runnable}
413+
* accordingly. The Runnable can represent either a synchronous method invocation
414+
* (see {@link #processScheduledSync(Scheduled, Method, Object)}) or an asynchronous
415+
* one (see {@link #processScheduledAsync(Scheduled, Method, Object)}).
416+
*/
417+
protected void processScheduledTask(Scheduled scheduled, Runnable runnable, Method method, Object bean) {
395418
try {
396-
Runnable runnable = createRunnable(bean, method);
397419
boolean processedSchedule = false;
398420
String errorMessage =
399421
"Exactly one of the 'cron', 'fixedDelay(String)', or 'fixedRate(String)' attributes is required";
@@ -516,6 +538,53 @@ protected void processScheduled(Scheduled scheduled, Method method, Object bean)
516538
}
517539
}
518540

541+
/**
542+
* Process the given {@code @Scheduled} method declaration on the given bean,
543+
* as a synchronous method. The method MUST take no arguments. Its return value
544+
* is ignored (if any) and the scheduled invocations of the method take place
545+
* using the underlying {@link TaskScheduler} infrastructure.
546+
* @param scheduled the {@code @Scheduled} annotation
547+
* @param method the method that the annotation has been declared on
548+
* @param bean the target bean instance
549+
* @see #createRunnable(Object, Method)
550+
*/
551+
protected void processScheduledSync(Scheduled scheduled, Method method, Object bean) {
552+
Runnable task;
553+
try {
554+
task = createRunnable(bean, method);
555+
}
556+
catch (IllegalArgumentException ex) {
557+
throw new IllegalStateException("Could not create recurring task for @Scheduled method '" + method.getName() + "': " + ex.getMessage());
558+
}
559+
processScheduledTask(scheduled, task, method, bean);
560+
}
561+
562+
/**
563+
* Process the given {@code @Scheduled} bean method declaration which returns
564+
* a {@code Publisher}, or the given Kotlin suspending function converted to a
565+
* Publisher. A {@code Runnable} which subscribes to that publisher is then repeatedly
566+
* scheduled according to the annotation configuration.
567+
* <p>Note that for fixed delay configuration, the subscription is turned into a blocking
568+
* call instead. Types for which a {@code ReactiveAdapter} is registered but which cannot
569+
* be deferred (i.e. not a {@code Publisher}) are not supported.
570+
* @param scheduled the {@code @Scheduled} annotation
571+
* @param method the method that the annotation has been declared on, which
572+
* MUST either return a Publisher-adaptable type or be a Kotlin suspending function
573+
* @param bean the target bean instance
574+
* @see ScheduledAnnotationReactiveSupport
575+
*/
576+
protected void processScheduledAsync(Scheduled scheduled, Method method, Object bean) {
577+
Runnable task;
578+
try {
579+
task = ScheduledAnnotationReactiveSupport.createSubscriptionRunnable(method, bean, scheduled,
580+
this.reactiveSubscriptions.computeIfAbsent(bean, k -> new CopyOnWriteArrayList<>()));
581+
}
582+
catch (IllegalArgumentException ex) {
583+
throw new IllegalStateException("Could not create recurring task for @Scheduled method '" + method.getName() + "': " + ex.getMessage());
584+
}
585+
processScheduledTask(scheduled, task, method, bean);
586+
}
587+
519588
/**
520589
* Create a {@link Runnable} for the given bean instance,
521590
* calling the specified scheduled method.
@@ -554,6 +623,8 @@ private static boolean isP(char ch) {
554623
/**
555624
* Return all currently scheduled tasks, from {@link Scheduled} methods
556625
* as well as from programmatic {@link SchedulingConfigurer} interaction.
626+
* <p>Note this includes upcoming scheduled subscriptions for reactive methods,
627+
* but doesn't cover any currently active subscription for such methods.
557628
* @since 5.0.2
558629
*/
559630
@Override
@@ -572,20 +643,27 @@ public Set<ScheduledTask> getScheduledTasks() {
572643
@Override
573644
public void postProcessBeforeDestruction(Object bean, String beanName) {
574645
Set<ScheduledTask> tasks;
646+
List<Runnable> liveSubscriptions;
575647
synchronized (this.scheduledTasks) {
576648
tasks = this.scheduledTasks.remove(bean);
649+
liveSubscriptions = this.reactiveSubscriptions.remove(bean);
577650
}
578651
if (tasks != null) {
579652
for (ScheduledTask task : tasks) {
580653
task.cancel();
581654
}
582655
}
656+
if (liveSubscriptions != null) {
657+
for (Runnable subscription : liveSubscriptions) {
658+
subscription.run(); // equivalent to cancelling the subscription
659+
}
660+
}
583661
}
584662

585663
@Override
586664
public boolean requiresDestruction(Object bean) {
587665
synchronized (this.scheduledTasks) {
588-
return this.scheduledTasks.containsKey(bean);
666+
return this.scheduledTasks.containsKey(bean) || this.reactiveSubscriptions.containsKey(bean);
589667
}
590668
}
591669

@@ -599,6 +677,12 @@ public void destroy() {
599677
}
600678
}
601679
this.scheduledTasks.clear();
680+
Collection<List<Runnable>> allLiveSubscriptions = this.reactiveSubscriptions.values();
681+
for (List<Runnable> liveSubscriptions : allLiveSubscriptions) {
682+
for (Runnable liveSubscription : liveSubscriptions) {
683+
liveSubscription.run(); //equivalent to cancelling the subscription
684+
}
685+
}
602686
}
603687
this.registrar.destroy();
604688
}

0 commit comments

Comments
 (0)