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
This commit is contained in:
Simon Baslé
2023-06-05 12:25:15 +02:00
committed by Brian Clozel
parent 53f891226e
commit 35052f2113
7 changed files with 877 additions and 4 deletions

View File

@@ -0,0 +1,238 @@
/*
* Copyright 2002-2023 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.scheduling.annotation;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Flowable;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.util.ReflectionUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.springframework.scheduling.annotation.ScheduledAnnotationReactiveSupport.createSubscriptionRunnable;
import static org.springframework.scheduling.annotation.ScheduledAnnotationReactiveSupport.getPublisherFor;
import static org.springframework.scheduling.annotation.ScheduledAnnotationReactiveSupport.isReactive;
class ScheduledAnnotationReactiveSupportTests {
@Test
void ensureReactor() {
assertThat(ScheduledAnnotationReactiveSupport.reactorPresent).isTrue();
}
@ParameterizedTest
@ValueSource(strings = { "mono", "flux", "monoString", "fluxString", "publisherMono",
"publisherString", "monoThrows", "flowable", "completable" }) //note: monoWithParams can't be found by this test
void checkIsReactive(String method) {
Method m = ReflectionUtils.findMethod(ReactiveMethods.class, method);
assertThat(isReactive(m)).as(m.getName()).isTrue();
}
@Test
void checkNotReactive() {
Method string = ReflectionUtils.findMethod(ReactiveMethods.class, "oops");
assertThat(isReactive(string))
.as("String-returning").isFalse();
}
@Test
void rejectReactiveAdaptableButNotDeferred() {
Method future = ReflectionUtils.findMethod(ReactiveMethods.class, "future");
assertThatIllegalArgumentException().isThrownBy(() -> isReactive(future))
.withMessage("Reactive methods may only be annotated with @Scheduled if the return type supports deferred execution");
}
@Test
void isReactiveRejectsWithParams() {
Method m = ReflectionUtils.findMethod(ReactiveMethods.class, "monoWithParam", String.class);
//isReactive rejects with context
assertThatIllegalArgumentException().isThrownBy(() -> isReactive(m))
.withMessage("Reactive methods may only be annotated with @Scheduled if declared without arguments")
.withNoCause();
}
@Test
void rejectCantProducePublisher() {
ReactiveMethods target = new ReactiveMethods();
Method m = ReflectionUtils.findMethod(ReactiveMethods.class, "monoThrows");
//static helper method
assertThatIllegalArgumentException().isThrownBy(() -> getPublisherFor(m, target))
.withMessage("Cannot obtain a Publisher-convertible value from the @Scheduled reactive method")
.withCause(new IllegalStateException("expected"));
}
@Test
void rejectCantAccessMethod() {
ReactiveMethods target = new ReactiveMethods();
Method m = ReflectionUtils.findMethod(ReactiveMethods.class, "monoThrowsIllegalAccess");
//static helper method
assertThatIllegalArgumentException().isThrownBy(() -> getPublisherFor(m, target))
.withMessage("Cannot obtain a Publisher-convertible value from the @Scheduled reactive method")
.withCause(new IllegalAccessException("expected"));
}
@Test
void fixedDelayIsBlocking() {
ReactiveMethods target = new ReactiveMethods();
Method m = ReflectionUtils.findMethod(ReactiveMethods.class, "mono");
Scheduled fixedDelayString = AnnotationUtils.synthesizeAnnotation(Map.of("fixedDelayString", "123"), Scheduled.class, null);
Scheduled fixedDelayLong = AnnotationUtils.synthesizeAnnotation(Map.of("fixedDelay", 123L), Scheduled.class, null);
List<Runnable> tracker = new ArrayList<>();
assertThat(createSubscriptionRunnable(m, target, fixedDelayString, tracker))
.isInstanceOfSatisfying(ScheduledAnnotationReactiveSupport.SubscribingRunnable.class, sr ->
assertThat(sr.shouldBlock).as("fixedDelayString.shouldBlock").isTrue()
);
assertThat(createSubscriptionRunnable(m, target, fixedDelayLong, tracker))
.isInstanceOfSatisfying(ScheduledAnnotationReactiveSupport.SubscribingRunnable.class, sr ->
assertThat(sr.shouldBlock).as("fixedDelayLong.shouldBlock").isTrue()
);
}
@Test
void fixedRateIsNotBlocking() {
ReactiveMethods target = new ReactiveMethods();
Method m = ReflectionUtils.findMethod(ReactiveMethods.class, "mono");
Scheduled fixedRateString = AnnotationUtils.synthesizeAnnotation(Map.of("fixedRateString", "123"), Scheduled.class, null);
Scheduled fixedRateLong = AnnotationUtils.synthesizeAnnotation(Map.of("fixedRate", 123L), Scheduled.class, null);
List<Runnable> tracker = new ArrayList<>();
assertThat(createSubscriptionRunnable(m, target, fixedRateString, tracker))
.isInstanceOfSatisfying(ScheduledAnnotationReactiveSupport.SubscribingRunnable.class, sr ->
assertThat(sr.shouldBlock).as("fixedRateString.shouldBlock").isFalse()
);
assertThat(createSubscriptionRunnable(m, target, fixedRateLong, tracker))
.isInstanceOfSatisfying(ScheduledAnnotationReactiveSupport.SubscribingRunnable.class, sr ->
assertThat(sr.shouldBlock).as("fixedRateLong.shouldBlock").isFalse()
);
}
@Test
void cronIsNotBlocking() {
ReactiveMethods target = new ReactiveMethods();
Method m = ReflectionUtils.findMethod(ReactiveMethods.class, "mono");
Scheduled cron = AnnotationUtils.synthesizeAnnotation(Map.of("cron", "-"), Scheduled.class, null);
List<Runnable> tracker = new ArrayList<>();
assertThat(createSubscriptionRunnable(m, target, cron, tracker))
.isInstanceOfSatisfying(ScheduledAnnotationReactiveSupport.SubscribingRunnable.class, sr ->
assertThat(sr.shouldBlock).as("cron.shouldBlock").isFalse()
);
}
@Test
void hasCheckpointToString() {
ReactiveMethods target = new ReactiveMethods();
Method m = ReflectionUtils.findMethod(ReactiveMethods.class, "mono");
Publisher<?> p = getPublisherFor(m, target);
assertThat(p.getClass().getName())
.as("checkpoint class")
.isEqualTo("reactor.core.publisher.FluxOnAssembly");
assertThat(p).hasToString("checkpoint(\"@Scheduled 'mono()' in bean 'org.springframework.scheduling.annotation.ScheduledAnnotationReactiveSupportTests$ReactiveMethods'\")");
}
static class ReactiveMethods {
public String oops() {
return "oops";
}
public Mono<Void> mono() {
return Mono.empty();
}
public Flux<Void> flux() {
return Flux.empty();
}
public Mono<String> monoString() {
return Mono.just("example");
}
public Flux<String> fluxString() {
return Flux.just("example");
}
public Publisher<Void> publisherMono() {
return Mono.empty();
}
public Publisher<String> publisherString() {
return fluxString();
}
public CompletableFuture<String> future() {
return CompletableFuture.completedFuture("example");
}
public Mono<Void> monoWithParam(String param) {
return Mono.just(param).then();
}
public Mono<Void> monoThrows() {
throw new IllegalStateException("expected");
}
public Mono<Void> monoThrowsIllegalAccess() throws IllegalAccessException {
//simulate a reflection issue
throw new IllegalAccessException("expected");
}
public Flowable<Void> flowable() {
return Flowable.empty();
}
public Completable completable() {
return Completable.complete();
}
AtomicInteger subscription = new AtomicInteger();
public Mono<Void> trackingMono() {
return Mono.<Void>empty()
.doOnSubscribe(s -> subscription.incrementAndGet());
}
public Mono<Void> monoError() {
return Mono.error(new IllegalStateException("expected"));
}
}
}

View File

@@ -0,0 +1,155 @@
/*
* Copyright 2002-2023 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.scheduling.annotation
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.assertj.core.api.Assertions
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import org.springframework.scheduling.annotation.ScheduledAnnotationReactiveSupport.getPublisherFor
import org.springframework.scheduling.annotation.ScheduledAnnotationReactiveSupport.isReactive
import org.springframework.util.ReflectionUtils
import reactor.core.publisher.Mono
import java.util.concurrent.atomic.AtomicInteger
import kotlin.coroutines.Continuation
class KotlinScheduledAnnotationReactiveSupportTests {
@Test
fun ensureReactor() {
assertThat(ScheduledAnnotationReactiveSupport.reactorPresent).isTrue
}
@Test
fun ensureKotlinCoroutineReactorBridge() {
assertThat(ScheduledAnnotationReactiveSupport.coroutinesReactorPresent).isTrue
}
@ParameterizedTest
@ValueSource(strings = ["suspending", "suspendingReturns"])
fun isReactiveSuspending(methodName: String) {
val method = ReflectionUtils.findMethod(SuspendingFunctions::class.java, methodName, Continuation::class.java)!!
assertThat(isReactive(method)).isTrue
}
@ParameterizedTest
@ValueSource(strings = ["flow", "deferred"])
fun isReactiveKotlinType(methodName: String) {
val method = ReflectionUtils.findMethod(SuspendingFunctions::class.java, methodName)!!
assertThat(isReactive(method)).isTrue
}
@Test
fun isNotReactive() {
val method = ReflectionUtils.findMethod(SuspendingFunctions::class.java, "notSuspending")!!
assertThat(isReactive(method)).isFalse
}
internal class SuspendingFunctions {
suspend fun suspending() {
}
suspend fun suspendingReturns(): String = "suspended"
suspend fun withParam(param: String): String {
return param
}
suspend fun throwsIllegalState() {
throw IllegalStateException("expected")
}
var subscription = AtomicInteger()
suspend fun suspendingTracking() {
subscription.incrementAndGet()
}
fun notSuspending() { }
fun flow(): Flow<Void> {
return flowOf()
}
fun deferred(): Deferred<Void> {
return CompletableDeferred()
}
}
private var target: SuspendingFunctions? = null
@BeforeEach
fun init() {
target = SuspendingFunctions()
}
@Test
fun checkKotlinRuntimeIfNeeded() {
val suspendingMethod = ReflectionUtils.findMethod(SuspendingFunctions::class.java, "suspending", Continuation::class.java)!!
val notSuspendingMethod = ReflectionUtils.findMethod(SuspendingFunctions::class.java, "notSuspending")!!
assertThat(isReactive(suspendingMethod)).describedAs("suspending").isTrue()
assertThat(isReactive(notSuspendingMethod)).describedAs("not suspending").isFalse()
}
@Test
fun isReactiveRejectsWithParams() {
val m = ReflectionUtils.findMethod(SuspendingFunctions::class.java, "withParam", String::class.java, Continuation::class.java)!!
//isReactive rejects with some context
Assertions.assertThatIllegalArgumentException().isThrownBy { isReactive(m) }
.withMessage("Kotlin suspending functions may only be annotated with @Scheduled if declared without arguments")
.withNoCause()
}
@Test
fun rejectNotSuspending() {
val m = ReflectionUtils.findMethod(SuspendingFunctions::class.java, "notSuspending")
//static helper method
Assertions.assertThatIllegalArgumentException().isThrownBy { getPublisherFor(m!!, target!!) }
.withMessage("Cannot convert the @Scheduled reactive method return type to Publisher")
.withNoCause()
}
@Test
fun suspendingThrowIsTurnedToMonoError() {
val m = ReflectionUtils.findMethod(SuspendingFunctions::class.java, "throwsIllegalState", Continuation::class.java)
val mono = Mono.from(getPublisherFor(m!!, target!!))
Assertions.assertThatIllegalStateException().isThrownBy { mono.block() }
.withMessage("expected")
.withNoCause()
}
@Test
fun turningSuspendingFunctionToMonoDoesntExecuteTheMethod() {
val m = ReflectionUtils.findMethod(SuspendingFunctions::class.java, "suspendingTracking", Continuation::class.java)
val mono = Mono.from(getPublisherFor(m!!, target!!))
assertThat(target!!.subscription).hasValue(0)
mono.block()
assertThat(target!!.subscription).describedAs("after subscription").hasValue(1)
}
}