Skip to content

Commit bbea54b

Browse files
committed
Controller method async execution on Java 21+
Closes gh-958
1 parent a323d22 commit bbea54b

File tree

11 files changed

+165
-33
lines changed

11 files changed

+165
-33
lines changed

spring-graphql/src/main/java/org/springframework/graphql/data/federation/EntityHandlerMethod.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@ final class EntityHandlerMethod extends DataFetcherHandlerMethodSupport {
4040

4141
EntityHandlerMethod(
4242
FederationSchemaFactory.EntityMappingInfo info, HandlerMethodArgumentResolverComposite resolvers,
43-
@Nullable Executor executor) {
43+
@Nullable Executor executor, boolean invokeAsync) {
4444

45-
super(info.handlerMethod(), resolvers, executor);
45+
super(info.handlerMethod(), resolvers, executor, invokeAsync);
4646
this.batchHandlerMethod = info.isBatchHandlerMethod();
4747
}
4848

spring-graphql/src/main/java/org/springframework/graphql/data/federation/FederationSchemaFactory.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@ public void afterPropertiesSet() {
9292
super.afterPropertiesSet();
9393

9494
detectHandlerMethods().forEach((info) ->
95-
this.handlerMethods.put(info.typeName(),
96-
new EntityHandlerMethod(info, getArgumentResolvers(), getExecutor())));
95+
this.handlerMethods.put(info.typeName(), new EntityHandlerMethod(
96+
info, getArgumentResolvers(), getExecutor(), shouldInvokeAsync(info.handlerMethod()))));
9797

9898
if (this.typeResolver == null) {
9999
this.typeResolver = new ClassNameTypeResolver();

spring-graphql/src/main/java/org/springframework/graphql/data/method/InvocableHandlerMethodSupport.java

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -47,25 +47,43 @@ public abstract class InvocableHandlerMethodSupport extends HandlerMethod {
4747
private static final Object NO_VALUE = new Object();
4848

4949

50-
private final boolean hasCallableReturnValue;
51-
5250
@Nullable
5351
private final Executor executor;
5452

53+
private final boolean hasCallableReturnValue;
54+
55+
private final boolean invokeAsync;
56+
57+
5558

5659
/**
5760
* Create an instance.
5861
* @param handlerMethod the controller method
5962
* @param executor an {@link Executor} to use for {@link Callable} return values
63+
* @deprecated in favor of alternative constructor
6064
*/
65+
@Deprecated(since = "1.3.0", forRemoval = true)
6166
protected InvocableHandlerMethodSupport(HandlerMethod handlerMethod, @Nullable Executor executor) {
67+
this(handlerMethod, executor, false);
68+
}
69+
70+
/**
71+
* Create an instance.
72+
* @param handlerMethod the controller method
73+
* @param executor an {@link Executor} to use for {@link Callable} return values
74+
* @param invokeAsync whether to invoke the method through the Executor
75+
* @since 1.3.0
76+
*/
77+
protected InvocableHandlerMethodSupport(
78+
HandlerMethod handlerMethod, @Nullable Executor executor, boolean invokeAsync) {
6279
super(handlerMethod.createWithResolvedBean());
6380

64-
this.hasCallableReturnValue = getReturnType().getParameterType().equals(Callable.class);
6581
this.executor = executor;
82+
this.hasCallableReturnValue = getReturnType().getParameterType().equals(Callable.class);
83+
this.invokeAsync = (invokeAsync && !this.hasCallableReturnValue);
6684

67-
Assert.isTrue(!this.hasCallableReturnValue || executor != null,
68-
"Controller method has Callable return value, but Executor not provided: " +
85+
Assert.isTrue((!this.hasCallableReturnValue && !invokeAsync) || executor != null,
86+
"Controller method has Callable return value or invokeAsync=true, but Executor not provided: " +
6987
handlerMethod.getBridgedMethod().toGenericString());
7088
}
7189

@@ -81,18 +99,24 @@ protected InvocableHandlerMethodSupport(HandlerMethod handlerMethod, @Nullable E
8199
@Nullable
82100
protected Object doInvoke(GraphQLContext graphQLContext, Object... argValues) {
83101
if (logger.isTraceEnabled()) {
84-
logger.trace("Arguments: " + Arrays.toString(argValues));
102+
logger.trace("Invoking " + getBridgedMethod().getName() + "(" + Arrays.toString(argValues) + ")");
85103
}
86104
Method method = getBridgedMethod();
87105
try {
88106
if (KotlinDetector.isSuspendingFunction(method)) {
89107
return invokeSuspendingFunction(getBean(), method, argValues);
90108
}
91109

92-
Object result = method.invoke(getBean(), argValues);
93-
94-
if (this.hasCallableReturnValue && result != null) {
95-
result = adaptCallable(graphQLContext, (Callable<?>) result);
110+
Object result;
111+
if (this.invokeAsync) {
112+
Callable<Object> callable = () -> method.invoke(getBean(), argValues);
113+
result = adaptCallable(graphQLContext, callable);
114+
}
115+
else {
116+
result = method.invoke(getBean(), argValues);
117+
if (this.hasCallableReturnValue && result != null) {
118+
result = adaptCallable(graphQLContext, (Callable<?>) result);
119+
}
96120
}
97121

98122
return result;

spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,8 @@ private void registerDataFetcher(DataFetcherMappingInfo info, RuntimeWiring.Buil
343343
DataFetcher<?> dataFetcher;
344344
if (!info.isBatchMapping()) {
345345
dataFetcher = new SchemaMappingDataFetcher(
346-
info, getArgumentResolvers(), this.validationHelper, getExceptionResolver(), getExecutor());
346+
info, getArgumentResolvers(), this.validationHelper, getExceptionResolver(),
347+
getExecutor(), shouldInvokeAsync(info.getHandlerMethod()));
347348
}
348349
else {
349350
dataFetcher = registerBatchLoader(info);
@@ -366,7 +367,8 @@ private DataFetcher<Object> registerBatchLoader(DataFetcherMappingInfo info) {
366367
}
367368

368369
HandlerMethod handlerMethod = info.getHandlerMethod();
369-
BatchLoaderHandlerMethod invocable = new BatchLoaderHandlerMethod(handlerMethod, getExecutor());
370+
BatchLoaderHandlerMethod invocable =
371+
new BatchLoaderHandlerMethod(handlerMethod, getExecutor(), shouldInvokeAsync(handlerMethod));
370372

371373
MethodParameter returnType = handlerMethod.getReturnType();
372374
Class<?> clazz = returnType.getParameterType();
@@ -441,12 +443,14 @@ static class SchemaMappingDataFetcher implements SelfDescribingDataFetcher<Objec
441443
@Nullable
442444
private final Executor executor;
443445

446+
private final boolean invokeAsync;
447+
444448
private final boolean subscription;
445449

446450
SchemaMappingDataFetcher(
447451
DataFetcherMappingInfo info, HandlerMethodArgumentResolverComposite argumentResolvers,
448452
@Nullable ValidationHelper helper, HandlerDataFetcherExceptionResolver exceptionResolver,
449-
@Nullable Executor executor) {
453+
@Nullable Executor executor, boolean invokeAsync) {
450454

451455
this.mappingInfo = info;
452456
this.argumentResolvers = argumentResolvers;
@@ -457,6 +461,7 @@ static class SchemaMappingDataFetcher implements SelfDescribingDataFetcher<Objec
457461
this.exceptionResolver = exceptionResolver;
458462

459463
this.executor = executor;
464+
this.invokeAsync = invokeAsync;
460465
this.subscription = this.mappingInfo.getCoordinates().getTypeName().equalsIgnoreCase("Subscription");
461466
}
462467

@@ -497,7 +502,7 @@ public Object get(DataFetchingEnvironment environment) throws Exception {
497502

498503
DataFetcherHandlerMethod handlerMethod = new DataFetcherHandlerMethod(
499504
getHandlerMethod(), this.argumentResolvers, this.methodValidationHelper,
500-
this.executor, this.subscription);
505+
this.executor, this.invokeAsync, this.subscription);
501506

502507
try {
503508
Object result = handlerMethod.invoke(environment);

spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerDetectionSupport.java

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import java.util.Set;
2828
import java.util.concurrent.Callable;
2929
import java.util.concurrent.Executor;
30+
import java.util.function.Predicate;
3031
import java.util.stream.Collectors;
3132

3233
import graphql.schema.DataFetcher;
@@ -37,7 +38,9 @@
3738
import org.springframework.beans.factory.InitializingBean;
3839
import org.springframework.context.ApplicationContext;
3940
import org.springframework.context.ApplicationContextAware;
41+
import org.springframework.core.KotlinDetector;
4042
import org.springframework.core.MethodIntrospector;
43+
import org.springframework.core.ReactiveAdapterRegistry;
4144
import org.springframework.core.annotation.AnnotatedElementUtils;
4245
import org.springframework.core.convert.ConversionService;
4346
import org.springframework.format.FormatterRegistrar;
@@ -47,9 +50,11 @@
4750
import org.springframework.graphql.data.method.HandlerMethodArgumentResolverComposite;
4851
import org.springframework.graphql.execution.DataFetcherExceptionResolver;
4952
import org.springframework.lang.Nullable;
53+
import org.springframework.scheduling.SchedulingTaskExecutor;
5054
import org.springframework.stereotype.Controller;
5155
import org.springframework.util.Assert;
5256
import org.springframework.util.ClassUtils;
57+
import org.springframework.util.ReflectionUtils;
5358

5459
/**
5560
* Convenient base for classes that find annotated controller method with argument
@@ -65,6 +70,9 @@ public abstract class AnnotatedControllerDetectionSupport<M> implements Applicat
6570
"org.springframework.security.core.context.SecurityContext",
6671
AnnotatedControllerDetectionSupport.class.getClassLoader());
6772

73+
private static final boolean virtualThreadsPresent =
74+
(ReflectionUtils.findMethod(Thread.class, "ofVirtual") != null);
75+
6876
/**
6977
* Bean name prefix for target beans behind scoped proxies. Used to exclude those
7078
* targets from handler method detection, in favor of the corresponding proxies.
@@ -91,6 +99,9 @@ public abstract class AnnotatedControllerDetectionSupport<M> implements Applicat
9199
@Nullable
92100
private Executor executor;
93101

102+
private Predicate<HandlerMethod> blockingMethodPredicate =
103+
(virtualThreadsPresent) ? new BlockingHandlerMethodPredicate() : ((method) -> false);
104+
94105
@Nullable
95106
private HandlerMethodArgumentResolverComposite argumentResolvers;
96107

@@ -148,20 +159,44 @@ public HandlerDataFetcherExceptionResolver getExceptionResolver() {
148159

149160
/**
150161
* Configure an {@link Executor} to use for asynchronous handling of
151-
* {@link Callable} return values from controller methods.
162+
* {@link Callable} return values from controller methods, as well as for
163+
* {@link #setBlockingMethodPredicate(Predicate) blocking controller methods}
164+
* on Java 21+.
152165
* <p>By default, this is not set in which case controller methods with a
153-
* {@code Callable} return value cannot be registered.
166+
* {@code Callable} return value are not supported, and blocking methods
167+
* will be invoked synchronously.
154168
* @param executor the executor to use
155169
*/
156170
public void setExecutor(Executor executor) {
157171
this.executor = executor;
158172
}
159173

174+
/**
175+
* Return the {@link #setExecutor(Executor) configured Executor}.
176+
*/
160177
@Nullable
161178
public Executor getExecutor() {
162179
return this.executor;
163180
}
164181

182+
/**
183+
* Configure a predicate to decide which controller methods are blocking.
184+
* On Java 21+, such methods are invoked asynchronously through the
185+
* {@link #setExecutor(Executor) configured Executor}, unless the executor
186+
* is a thread pool executor as determined via
187+
* {@link SchedulingTaskExecutor#prefersShortLivedTasks() prefersShortLivedTasks}.
188+
* <p>By default, on Java 21+ the predicate returns false for controller
189+
* method return types known to {@link ReactiveAdapterRegistry} as well as
190+
* {@link KotlinDetector#isSuspendingFunction Kotlin suspending functions}.
191+
* On Java 20 and lower, the predicate returns false. You can configure the
192+
* predicate for more control, or alternatively, return {@link Callable}.
193+
* @param predicate the predicate to use
194+
* @since 1.3
195+
*/
196+
public void setBlockingMethodPredicate(@Nullable Predicate<HandlerMethod> predicate) {
197+
this.blockingMethodPredicate = ((predicate != null) ? predicate : (handlerMethod) -> false);
198+
}
199+
165200
/**
166201
* Return the configured argument resolvers.
167202
*/
@@ -287,4 +322,20 @@ protected HandlerMethod createHandlerMethod(Method originalMethod, Object handle
287322
new HandlerMethod(handler, method);
288323
}
289324

325+
protected boolean shouldInvokeAsync(HandlerMethod handlerMethod) {
326+
return (this.blockingMethodPredicate.test(handlerMethod) && this.executor != null &&
327+
!(this.executor instanceof SchedulingTaskExecutor ste && ste.prefersShortLivedTasks()));
328+
}
329+
330+
331+
private static final class BlockingHandlerMethodPredicate implements Predicate<HandlerMethod> {
332+
333+
@Override
334+
public boolean test(HandlerMethod hm) {
335+
Class<?> returnType = hm.getReturnType().getParameterType();
336+
return (ReactiveAdapterRegistry.getSharedInstance().getAdapter(returnType) == null &&
337+
!KotlinDetector.isSuspendingFunction(hm.getMethod()));
338+
}
339+
}
340+
290341
}

spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerExceptionResolver.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ private Mono<List<GraphQLError>> invokeExceptionHandler(
216216

217217
DataFetcherHandlerMethod exceptionHandler = new DataFetcherHandlerMethod(
218218
new HandlerMethod(controllerOrAdvice, methodHolder.getMethod()), this.argumentResolvers,
219-
null, null, false);
219+
null, null, false, false);
220220

221221
List<Throwable> exceptions = new ArrayList<>();
222222
try {

spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/BatchLoaderHandlerMethod.java

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2023 the original author or authors.
2+
* Copyright 2002-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -20,6 +20,7 @@
2020
import java.util.Arrays;
2121
import java.util.Collection;
2222
import java.util.Map;
23+
import java.util.concurrent.Callable;
2324
import java.util.concurrent.CompletableFuture;
2425
import java.util.concurrent.Executor;
2526
import java.util.function.Function;
@@ -59,8 +60,26 @@ public class BatchLoaderHandlerMethod extends InvocableHandlerMethodSupport {
5960
private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
6061

6162

63+
/**
64+
* Create an instance.
65+
* @param handlerMethod the controller method
66+
* @param executor an {@link Executor} to use for {@link Callable} return values
67+
* @deprecated in favor of alternative constructor
68+
*/
69+
@Deprecated(since = "1.3.0", forRemoval = true)
6270
public BatchLoaderHandlerMethod(HandlerMethod handlerMethod, @Nullable Executor executor) {
63-
super(handlerMethod, executor);
71+
this(handlerMethod, executor, false);
72+
}
73+
74+
/**
75+
* Create an instance.
76+
* @param handlerMethod the controller method
77+
* @param executor an {@link Executor} to use for {@link Callable} return values
78+
* @param invokeAsync whether to invoke the method through the Executor
79+
* @since 1.3.0
80+
*/
81+
public BatchLoaderHandlerMethod(HandlerMethod handlerMethod, @Nullable Executor executor, boolean invokeAsync) {
82+
super(handlerMethod, executor, invokeAsync);
6483
}
6584

6685

spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetcherHandlerMethod.java

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,33 @@ public class DataFetcherHandlerMethod extends DataFetcherHandlerMethodSupport {
5353
* @param validationHelper to apply bean validation with
5454
* @param executor an {@link Executor} to use for {@link Callable} return values
5555
* @param subscription whether the field being fetched is of subscription type
56+
* @deprecated in favor of alternative constructor
5657
*/
58+
@Deprecated(since = "1.3.0", forRemoval = true)
5759
public DataFetcherHandlerMethod(
5860
HandlerMethod handlerMethod, HandlerMethodArgumentResolverComposite resolvers,
5961
@Nullable BiConsumer<Object, Object[]> validationHelper, @Nullable Executor executor,
6062
boolean subscription) {
6163

62-
super(handlerMethod, resolvers, executor);
64+
this(handlerMethod, resolvers, validationHelper, executor, subscription, false);
65+
}
66+
67+
/**
68+
* Constructor with a parent handler method.
69+
* @param handlerMethod the handler method
70+
* @param resolvers the argument resolvers
71+
* @param validationHelper to apply bean validation with
72+
* @param executor an {@link Executor} to use for {@link Callable} return values
73+
* @param subscription whether the field being fetched is of subscription type
74+
* @param invokeAsync whether to invoke the method through the Executor
75+
* @since 1.3.0
76+
*/
77+
public DataFetcherHandlerMethod(
78+
HandlerMethod handlerMethod, HandlerMethodArgumentResolverComposite resolvers,
79+
@Nullable BiConsumer<Object, Object[]> validationHelper,
80+
@Nullable Executor executor, boolean invokeAsync, boolean subscription) {
81+
82+
super(handlerMethod, resolvers, executor, invokeAsync);
6383
Assert.isTrue(!resolvers.getResolvers().isEmpty(), "No argument resolvers");
6484
this.validationHelper = (validationHelper != null) ? validationHelper : (controller, args) -> { };
6585
this.subscription = subscription;

spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetcherHandlerMethodSupport.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,9 @@ public class DataFetcherHandlerMethodSupport extends InvocableHandlerMethodSuppo
4848

4949
protected DataFetcherHandlerMethodSupport(
5050
HandlerMethod handlerMethod, HandlerMethodArgumentResolverComposite resolvers,
51-
@Nullable Executor executor) {
51+
@Nullable Executor executor, boolean invokeAsync) {
5252

53-
super(handlerMethod, executor);
53+
super(handlerMethod, executor, invokeAsync);
5454
this.resolvers = resolvers;
5555
}
5656

spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ContextValueMethodArgumentResolverTests.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ void resolveMono() throws Exception {
125125

126126
DataFetcherHandlerMethod handlerMethod = new DataFetcherHandlerMethod(
127127
new HandlerMethod(new TestController(), TestController.class.getMethod("handleMono", Mono.class)),
128-
resolvers, null, null, false);
128+
resolvers, null, null, false, false);
129129

130130
GraphQLContext graphQLContext = new GraphQLContext.Builder().build();
131131

@@ -147,7 +147,7 @@ void resolveFromParameterNameWithBatchMapping() throws Exception {
147147

148148
BatchLoaderHandlerMethod handlerMethod = new BatchLoaderHandlerMethod(
149149
new HandlerMethod(controller,
150-
TestController.class.getMethod("getAuthors", List.class, Long.class)), null);
150+
TestController.class.getMethod("getAuthors", List.class, Long.class)), null, false);
151151

152152
GraphQLContext context = new GraphQLContext.Builder().build();
153153
context.put("id", 123L);

0 commit comments

Comments
 (0)