From 1484c14ed127acfeec23ead0b5b6734a02eeea59 Mon Sep 17 00:00:00 2001 From: Fernando Rodrigues Ripol Date: Sat, 3 Oct 2026 22:35:27 -0300 Subject: [PATCH 1/5] Add gRPC request scope support (#392) Signed-off-by: Your Name Signed-off-by: Fernando Rodrigues Ripol --- .../grpc/server/scope/GrpcRequestScope.java | 69 +++++++++++++++++++ .../GrpcRequestScopeAutoConfiguration.java | 48 +++++++++++++ .../GrpcRequestScopeServerInterceptor.java | 61 ++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScope.java create mode 100644 spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScopeAutoConfiguration.java create mode 100644 spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScopeServerInterceptor.java diff --git a/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScope.java b/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScope.java new file mode 100644 index 00000000..82748049 --- /dev/null +++ b/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScope.java @@ -0,0 +1,69 @@ +/* + * Copyright 2024-present 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.grpc.server.scope; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.beans.factory.config.Scope; + +/** + * A {@link Scope} implementation for gRPC request-scoped beans. + * + * @author Fernando Rodrigues Ripol + */ +public class GrpcRequestScope implements Scope { + + private static final ThreadLocal> threadLocalScope = ThreadLocal.withInitial(HashMap::new); + + @Override + public Object get(String name, ObjectFactory objectFactory) { + Map scope = threadLocalScope.get(); + Object bean = scope.get(name); + if (bean == null) { + bean = objectFactory.getObject(); + scope.put(name, bean); + } + return bean; + } + + @Override + public Object remove(String name) { + Map scope = threadLocalScope.get(); + return scope.remove(name); + } + + @Override + public void registerDestructionCallback(String name, Runnable callback) { + } + + @Override + public Object resolveContextualObject(String key) { + return null; + } + + @Override + public String getConversationId() { + return null; + } + + public static void clear() { + threadLocalScope.remove(); + } + +} diff --git a/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScopeAutoConfiguration.java b/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScopeAutoConfiguration.java new file mode 100644 index 00000000..3ac372b8 --- /dev/null +++ b/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScopeAutoConfiguration.java @@ -0,0 +1,48 @@ +/* + * Copyright 2024-present 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.grpc.server.scope; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Auto-configuration to register the gRPC request scope and its server interceptor. + * + * @author Fernando Rodrigues Ripol + */ +@Configuration(proxyBeanMethods = false) +public class GrpcRequestScopeAutoConfiguration { + + @Bean + public static BeanFactoryPostProcessor grpcRequestScopeBeanFactoryPostProcessor() { + return new BeanFactoryPostProcessor() { + @Override + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { + beanFactory.registerScope("grpcRequest", new GrpcRequestScope()); + } + }; + } + + @Bean + public GrpcRequestScopeServerInterceptor grpcRequestScopeServerInterceptor() { + return new GrpcRequestScopeServerInterceptor(); + } + +} diff --git a/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScopeServerInterceptor.java b/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScopeServerInterceptor.java new file mode 100644 index 00000000..838e905c --- /dev/null +++ b/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScopeServerInterceptor.java @@ -0,0 +1,61 @@ +/* + * Copyright 2024-present 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.grpc.server.scope; + +import io.grpc.ForwardingServerCallListener; +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; + +/** + * A {@link ServerInterceptor} to manage the lifecycle of gRPC request-scoped beans. + * + * @author Fernando Rodrigues Ripol + */ +public class GrpcRequestScopeServerInterceptor implements ServerInterceptor { + + @Override + public ServerCall.Listener interceptCall( + ServerCall call, + Metadata headers, + ServerCallHandler next) { + ServerCall.Listener delegate = next.startCall(call, headers); + return new ForwardingServerCallListener.SimpleForwardingServerCallListener(delegate) { + @Override + public void onComplete() { + try { + super.onComplete(); + } + finally { + GrpcRequestScope.clear(); + } + } + + @Override + public void onCancel() { + try { + super.onCancel(); + } + finally { + GrpcRequestScope.clear(); + } + } + }; + } + +} From 2c2a10c5c849cf94a2bab31854e20c6a591de8f5 Mon Sep 17 00:00:00 2001 From: Fernando Rodrigues Ripol Date: Mon, 12 Oct 2026 13:38:00 -0300 Subject: [PATCH 2/5] Refactor request scope to use @EnableGrpcScope and ImportBeanDefinitionRegistrar Signed-off-by: Fernando Ripol Signed-off-by: Fernando Rodrigues Ripol --- .../grpc/server/scope/EnableGrpcScope.java | 33 +++++++++++++ .../GrpcRequestScopeAutoConfiguration.java | 48 ------------------- .../GrpcRequestScopeServerInterceptor.java | 4 +- .../grpc/server/scope/GrpcScopeRegistrar.java | 37 ++++++++++++++ 4 files changed, 71 insertions(+), 51 deletions(-) create mode 100644 spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/EnableGrpcScope.java delete mode 100644 spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScopeAutoConfiguration.java create mode 100644 spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcScopeRegistrar.java diff --git a/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/EnableGrpcScope.java b/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/EnableGrpcScope.java new file mode 100644 index 00000000..424781c4 --- /dev/null +++ b/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/EnableGrpcScope.java @@ -0,0 +1,33 @@ +/* + * Copyright 2024-present 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.grpc.server.scope; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.context.annotation.Import; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Import(GrpcScopeRegistrar.class) +public @interface EnableGrpcScope { + +} diff --git a/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScopeAutoConfiguration.java b/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScopeAutoConfiguration.java deleted file mode 100644 index 3ac372b8..00000000 --- a/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScopeAutoConfiguration.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2024-present 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.grpc.server.scope; - -import org.springframework.beans.BeansException; -import org.springframework.beans.factory.config.BeanFactoryPostProcessor; -import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -/** - * Auto-configuration to register the gRPC request scope and its server interceptor. - * - * @author Fernando Rodrigues Ripol - */ -@Configuration(proxyBeanMethods = false) -public class GrpcRequestScopeAutoConfiguration { - - @Bean - public static BeanFactoryPostProcessor grpcRequestScopeBeanFactoryPostProcessor() { - return new BeanFactoryPostProcessor() { - @Override - public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { - beanFactory.registerScope("grpcRequest", new GrpcRequestScope()); - } - }; - } - - @Bean - public GrpcRequestScopeServerInterceptor grpcRequestScopeServerInterceptor() { - return new GrpcRequestScopeServerInterceptor(); - } - -} diff --git a/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScopeServerInterceptor.java b/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScopeServerInterceptor.java index 838e905c..b827186b 100644 --- a/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScopeServerInterceptor.java +++ b/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScopeServerInterceptor.java @@ -30,9 +30,7 @@ public class GrpcRequestScopeServerInterceptor implements ServerInterceptor { @Override - public ServerCall.Listener interceptCall( - ServerCall call, - Metadata headers, + public ServerCall.Listener interceptCall(ServerCall call, Metadata headers, ServerCallHandler next) { ServerCall.Listener delegate = next.startCall(call, headers); return new ForwardingServerCallListener.SimpleForwardingServerCallListener(delegate) { diff --git a/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcScopeRegistrar.java b/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcScopeRegistrar.java new file mode 100644 index 00000000..24736e25 --- /dev/null +++ b/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcScopeRegistrar.java @@ -0,0 +1,37 @@ +/* + * Copyright 2024-present 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.grpc.server.scope; + +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; +import org.springframework.core.type.AnnotationMetadata; + +public class GrpcScopeRegistrar implements ImportBeanDefinitionRegistrar { + + @Override + public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) { + if (!registry.containsBeanDefinition("grpcRequestScope")) { + registry.registerBeanDefinition("grpcRequestScope", new RootBeanDefinition(GrpcRequestScope.class)); + } + if (!registry.containsBeanDefinition("grpcRequestScopeServerInterceptor")) { + registry.registerBeanDefinition("grpcRequestScopeServerInterceptor", + new RootBeanDefinition(GrpcRequestScopeServerInterceptor.class)); + } + } + +} From 2b79c4e5be2636e7c4575eb45dddbda05f849c86 Mon Sep 17 00:00:00 2001 From: Fernando Rodrigues Ripol Date: Thu, 15 Oct 2026 14:17:37 -0300 Subject: [PATCH 3/5] Add @GrpcRequestScope annotation for user bean definitions Signed-off-by: Fernando Rodrigues Ripol --- .../server/annotation/GrpcRequestScope.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 spring-grpc-core/src/main/java/org/springframework/grpc/server/annotation/GrpcRequestScope.java diff --git a/spring-grpc-core/src/main/java/org/springframework/grpc/server/annotation/GrpcRequestScope.java b/spring-grpc-core/src/main/java/org/springframework/grpc/server/annotation/GrpcRequestScope.java new file mode 100644 index 00000000..32340dcf --- /dev/null +++ b/spring-grpc-core/src/main/java/org/springframework/grpc/server/annotation/GrpcRequestScope.java @@ -0,0 +1,22 @@ +package org.springframework.grpc.server.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.core.annotation.AliasFor; +import org.springframework.context.annotation.Scope; +import org.springframework.context.annotation.ScopedProxyMode; + +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Scope("grpcRequest") +public @interface GrpcRequestScope { + + @AliasFor(annotation = Scope.class) + ScopedProxyMode proxyMode() default ScopedProxyMode.TARGET_CLASS; + +} \ No newline at end of file From 8434508aa8940a0e2923440d8a8d82939179021c Mon Sep 17 00:00:00 2001 From: Fernando Rodrigues Ripol Date: Fri, 16 Oct 2026 20:58:54 -0300 Subject: [PATCH 4/5] Restore GrpcRequestScope integration tests with correct bean proxy target and style Signed-off-by: Fernando Rodrigues Ripol --- .../server/annotation/GrpcRequestScope.java | 22 ++++++-- .../annotation/GrpcRequestScopeTests.java | 53 +++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 spring-grpc-core/src/test/java/org/springframework/grpc/server/annotation/GrpcRequestScopeTests.java diff --git a/spring-grpc-core/src/main/java/org/springframework/grpc/server/annotation/GrpcRequestScope.java b/spring-grpc-core/src/main/java/org/springframework/grpc/server/annotation/GrpcRequestScope.java index 32340dcf..ed7665de 100644 --- a/spring-grpc-core/src/main/java/org/springframework/grpc/server/annotation/GrpcRequestScope.java +++ b/spring-grpc-core/src/main/java/org/springframework/grpc/server/annotation/GrpcRequestScope.java @@ -1,3 +1,19 @@ +/* + * Copyright 2024-present 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.grpc.server.annotation; import java.lang.annotation.Documented; @@ -6,11 +22,11 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import org.springframework.core.annotation.AliasFor; import org.springframework.context.annotation.Scope; import org.springframework.context.annotation.ScopedProxyMode; +import org.springframework.core.annotation.AliasFor; -@Target({ElementType.TYPE, ElementType.METHOD}) +@Target({ ElementType.TYPE, ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) @Documented @Scope("grpcRequest") @@ -19,4 +35,4 @@ @AliasFor(annotation = Scope.class) ScopedProxyMode proxyMode() default ScopedProxyMode.TARGET_CLASS; -} \ No newline at end of file +} diff --git a/spring-grpc-core/src/test/java/org/springframework/grpc/server/annotation/GrpcRequestScopeTests.java b/spring-grpc-core/src/test/java/org/springframework/grpc/server/annotation/GrpcRequestScopeTests.java new file mode 100644 index 00000000..1b4961d5 --- /dev/null +++ b/spring-grpc-core/src/test/java/org/springframework/grpc/server/annotation/GrpcRequestScopeTests.java @@ -0,0 +1,53 @@ +/* + * Copyright 2024-present 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.grpc.server.annotation; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.server.scope.EnableGrpcScope; + +class GrpcRequestScopeTests { + + @Test + void testGrpcRequestScopeRegistration() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(TestConfig.class)) { + assertThat(context.containsBean("testBean")).isTrue(); + } + } + + @Configuration + @EnableGrpcScope + static class TestConfig { + + @Bean + @GrpcRequestScope + public TestBean testBean() { + return new TestBean(); + } + + } + + static class TestBean { + + } + +} From f9b11b75477dda1ec438e585c633935f348519f5 Mon Sep 17 00:00:00 2001 From: Sharang Gupta Date: Mon, 26 Oct 2026 11:32:53 +0000 Subject: [PATCH 5/5] Key the gRPC request scope on the call's Context Hold the beans of a call in its io.grpc.Context rather than in a ThreadLocal, so that every thread the context is propagated to sees the same instances and nothing is created outside of a call. Run the destruction callbacks when the context is cancelled, which covers completion and cancellation alike. Register the scope with the bean factory through a CustomScopeConfigurer and make the interceptor a global one that runs first, so that the scope is open for the other interceptors in the chain. Cover the scope with in-process call tests and document it. See gh-392 Signed-off-by: Sharang Gupta --- .../grpc/server/scope/GrpcRequestScope.java | 174 +++++++++- .../GrpcRequestScopeServerInterceptor.java | 48 ++- .../grpc/server/scope/GrpcScopeRegistrar.java | 30 +- ...rpcRequestScopeServerInterceptorTests.java | 314 ++++++++++++++++++ .../antora/modules/ROOT/pages/server.adoc | 26 ++ .../antora/modules/ROOT/pages/whats-new.adoc | 1 + 6 files changed, 545 insertions(+), 48 deletions(-) create mode 100644 spring-grpc-core/src/test/java/org/springframework/grpc/server/scope/GrpcRequestScopeServerInterceptorTests.java diff --git a/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScope.java b/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScope.java index 82748049..a8ada7b0 100644 --- a/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScope.java +++ b/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScope.java @@ -16,54 +16,192 @@ package org.springframework.grpc.server.scope; -import java.util.HashMap; -import java.util.Map; +import java.util.Iterator; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; import org.springframework.beans.factory.ObjectFactory; import org.springframework.beans.factory.config.Scope; +import org.springframework.util.Assert; + +import io.grpc.Context; /** - * A {@link Scope} implementation for gRPC request-scoped beans. + * A {@link Scope} lasting for the duration of a gRPC call. The beans of a call are held + * in the call's {@link Context gRPC context}, so they are shared by every thread to which + * that context is propagated, and are destroyed when the context is cancelled, that is + * when the call completes or is cancelled. Beans are created on first use within a call; + * nothing can be looked up outside of a call or once the call has ended. * * @author Fernando Rodrigues Ripol + * @author Sharang Gupta + * @see GrpcRequestScopeServerInterceptor + * @see org.springframework.grpc.server.annotation.GrpcRequestScope */ public class GrpcRequestScope implements Scope { - private static final ThreadLocal> threadLocalScope = ThreadLocal.withInitial(HashMap::new); + /** + * The name of the scope. + */ + public static final String SCOPE_NAME = "grpcRequest"; + + private static final Log logger = LogFactory.getLog(GrpcRequestScope.class); + + private static final Context.Key SCOPED_BEANS = Context.key("grpcRequestScope"); + + /** + * Create the context of a new call, holding the beans of that call. They are + * destroyed when the context is cancelled. + * @param parent the current context + * @return the context to run the call in + */ + static Context newCallContext(Context parent) { + ScopedBeans scopedBeans = new ScopedBeans(); + Context context = parent.withValue(SCOPED_BEANS, scopedBeans); + context.addListener((cancelled) -> scopedBeans.destroy(), Runnable::run); + return context; + } @Override public Object get(String name, ObjectFactory objectFactory) { - Map scope = threadLocalScope.get(); - Object bean = scope.get(name); - if (bean == null) { - bean = objectFactory.getObject(); - scope.put(name, bean); - } - return bean; + return currentScopedBeans().get(name, objectFactory); } @Override - public Object remove(String name) { - Map scope = threadLocalScope.get(); - return scope.remove(name); + public @Nullable Object remove(String name) { + return currentScopedBeans().remove(name); } @Override public void registerDestructionCallback(String name, Runnable callback) { + currentScopedBeans().registerDestructionCallback(name, callback); } @Override - public Object resolveContextualObject(String key) { + public @Nullable Object resolveContextualObject(String key) { return null; } @Override - public String getConversationId() { + public @Nullable String getConversationId() { return null; } - public static void clear() { - threadLocalScope.remove(); + private static ScopedBeans currentScopedBeans() { + ScopedBeans scopedBeans = SCOPED_BEANS.get(); + Assert.state(scopedBeans != null, + () -> "No gRPC call is active in the current context: the " + SCOPE_NAME + + " scope is only available within a call intercepted by GrpcRequestScopeServerInterceptor" + + " and in threads to which the call's io.grpc.Context has been propagated"); + return scopedBeans; + } + + /** + * The beans of one call. Creation is serialized per bean name, not per call, and + * nothing can be looked up once the call's beans have been destroyed. + */ + private static final class ScopedBeans { + + private final ConcurrentMap beans = new ConcurrentHashMap<>(); + + private volatile boolean destroyed; + + Object get(String name, ObjectFactory objectFactory) { + assertNotDestroyed(); + ScopedBean bean = this.beans.computeIfAbsent(name, (key) -> new ScopedBean(key, objectFactory)); + Object instance = bean.get(); + if (this.destroyed) { + // Lost the race against the end of the call: nothing may outlive it. + ScopedBean late = this.beans.remove(name); + if (late != null) { + late.destroy(); + } + assertNotDestroyed(); + } + return instance; + } + + @Nullable + Object remove(String name) { + ScopedBean bean = this.beans.remove(name); + return (bean != null) ? bean.getIfCreated() : null; + } + + void registerDestructionCallback(String name, Runnable callback) { + ScopedBean bean = this.beans.get(name); + if (bean != null) { + bean.setDestructionCallback(callback); + } + } + + void destroy() { + this.destroyed = true; + Iterator iterator = this.beans.values().iterator(); + while (iterator.hasNext()) { + ScopedBean bean = iterator.next(); + iterator.remove(); + bean.destroy(); + } + } + + private void assertNotDestroyed() { + Assert.state(!this.destroyed, + () -> "The gRPC call has completed and its " + SCOPE_NAME + " scoped beans have been destroyed"); + } + + } + + /** + * One bean of a call, created on first access and destroyed at most once. + */ + private static final class ScopedBean { + + private final String name; + + private final ObjectFactory objectFactory; + + private @Nullable Object instance; + + private @Nullable Runnable destructionCallback; + + ScopedBean(String name, ObjectFactory objectFactory) { + this.name = name; + this.objectFactory = objectFactory; + } + + synchronized Object get() { + if (this.instance == null) { + this.instance = this.objectFactory.getObject(); + } + return this.instance; + } + + synchronized @Nullable Object getIfCreated() { + return this.instance; + } + + synchronized void setDestructionCallback(Runnable callback) { + this.destructionCallback = callback; + } + + synchronized void destroy() { + Runnable callback = this.destructionCallback; + this.destructionCallback = null; + this.instance = null; + if (callback != null) { + try { + callback.run(); + } + catch (Throwable ex) { + logger.error("Destruction callback of gRPC request scoped bean '" + this.name + "' failed", ex); + } + } + } + } } diff --git a/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScopeServerInterceptor.java b/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScopeServerInterceptor.java index b827186b..eb4da1c6 100644 --- a/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScopeServerInterceptor.java +++ b/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcRequestScopeServerInterceptor.java @@ -16,44 +16,42 @@ package org.springframework.grpc.server.scope; -import io.grpc.ForwardingServerCallListener; +import org.springframework.core.Ordered; +import org.springframework.grpc.server.GlobalServerInterceptor; + +import io.grpc.Context; +import io.grpc.Contexts; import io.grpc.Metadata; import io.grpc.ServerCall; import io.grpc.ServerCallHandler; import io.grpc.ServerInterceptor; /** - * A {@link ServerInterceptor} to manage the lifecycle of gRPC request-scoped beans. + * A global server interceptor that opens a new {@link GrpcRequestScope} for every call it + * intercepts, ahead of the other interceptors in the service chain, so that request + * scoped beans are available to them. The beans of the call are destroyed when the call + * completes or is cancelled. * * @author Fernando Rodrigues Ripol + * @author Sharang Gupta + * @see EnableGrpcScope */ -public class GrpcRequestScopeServerInterceptor implements ServerInterceptor { +@GlobalServerInterceptor +public class GrpcRequestScopeServerInterceptor implements ServerInterceptor, Ordered { + + /** + * Runs before the other interceptors in the chain so that scoped beans are available + * to them. + */ + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE; + } @Override public ServerCall.Listener interceptCall(ServerCall call, Metadata headers, ServerCallHandler next) { - ServerCall.Listener delegate = next.startCall(call, headers); - return new ForwardingServerCallListener.SimpleForwardingServerCallListener(delegate) { - @Override - public void onComplete() { - try { - super.onComplete(); - } - finally { - GrpcRequestScope.clear(); - } - } - - @Override - public void onCancel() { - try { - super.onCancel(); - } - finally { - GrpcRequestScope.clear(); - } - } - }; + return Contexts.interceptCall(GrpcRequestScope.newCallContext(Context.current()), call, headers, next); } } diff --git a/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcScopeRegistrar.java b/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcScopeRegistrar.java index 24736e25..4d4650c2 100644 --- a/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcScopeRegistrar.java +++ b/spring-grpc-core/src/main/java/org/springframework/grpc/server/scope/GrpcScopeRegistrar.java @@ -16,21 +16,41 @@ package org.springframework.grpc.server.scope; +import java.util.Map; + +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.CustomScopeConfigurer; import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.beans.factory.support.RootBeanDefinition; import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; import org.springframework.core.type.AnnotationMetadata; +/** + * Registers the {@value GrpcRequestScope#SCOPE_NAME} scope with the bean factory and the + * global server interceptor that opens it for every call. + * + * @author Fernando Rodrigues Ripol + * @author Sharang Gupta + * @see EnableGrpcScope + */ public class GrpcScopeRegistrar implements ImportBeanDefinitionRegistrar { + static final String SCOPE_CONFIGURER_BEAN_NAME = "grpcRequestScopeConfigurer"; + + static final String INTERCEPTOR_BEAN_NAME = "grpcRequestScopeServerInterceptor"; + @Override public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) { - if (!registry.containsBeanDefinition("grpcRequestScope")) { - registry.registerBeanDefinition("grpcRequestScope", new RootBeanDefinition(GrpcRequestScope.class)); + if (!registry.containsBeanDefinition(SCOPE_CONFIGURER_BEAN_NAME)) { + RootBeanDefinition configurer = new RootBeanDefinition(CustomScopeConfigurer.class); + configurer.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); + configurer.getPropertyValues().add("scopes", Map.of(GrpcRequestScope.SCOPE_NAME, new GrpcRequestScope())); + registry.registerBeanDefinition(SCOPE_CONFIGURER_BEAN_NAME, configurer); } - if (!registry.containsBeanDefinition("grpcRequestScopeServerInterceptor")) { - registry.registerBeanDefinition("grpcRequestScopeServerInterceptor", - new RootBeanDefinition(GrpcRequestScopeServerInterceptor.class)); + if (!registry.containsBeanDefinition(INTERCEPTOR_BEAN_NAME)) { + RootBeanDefinition interceptor = new RootBeanDefinition(GrpcRequestScopeServerInterceptor.class); + interceptor.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); + registry.registerBeanDefinition(INTERCEPTOR_BEAN_NAME, interceptor); } } diff --git a/spring-grpc-core/src/test/java/org/springframework/grpc/server/scope/GrpcRequestScopeServerInterceptorTests.java b/spring-grpc-core/src/test/java/org/springframework/grpc/server/scope/GrpcRequestScopeServerInterceptorTests.java new file mode 100644 index 00000000..016a9481 --- /dev/null +++ b/spring-grpc-core/src/test/java/org/springframework/grpc/server/scope/GrpcRequestScopeServerInterceptorTests.java @@ -0,0 +1,314 @@ +/* + * Copyright 2024-present 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.grpc.server.scope; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.support.ScopeNotActiveException; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.internal.ApplicationContextBeanLookupUtils; +import org.springframework.grpc.server.GlobalServerInterceptor; +import org.springframework.grpc.server.annotation.GrpcRequestScope; +import org.springframework.grpc.server.service.DefaultGrpcServiceConfigurer; +import org.springframework.grpc.server.service.GrpcServiceSpec; + +import com.google.protobuf.Empty; +import io.grpc.BindableService; +import io.grpc.CallOptions; +import io.grpc.Context; +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.Server; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.ServerInterceptors; +import io.grpc.ServerServiceDefinition; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.protobuf.ProtoUtils; +import io.grpc.stub.ClientCalls; + +/** + * Tests for {@link GrpcRequestScopeServerInterceptor} and the scope it opens, activated + * with {@link EnableGrpcScope @EnableGrpcScope}. + */ +class GrpcRequestScopeServerInterceptorTests { + + private static final String SERVICE_NAME = "test.ScopedService"; + + private static final MethodDescriptor METHOD = MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName(MethodDescriptor.generateFullMethodName(SERVICE_NAME, "Get")) + .setRequestMarshaller(ProtoUtils.marshaller(Empty.getDefaultInstance())) + .setResponseMarshaller(ProtoUtils.marshaller(Empty.getDefaultInstance())) + .build(); + + private final List observedIds = new CopyOnWriteArrayList<>(); + + private final ExecutorService worker = Executors.newSingleThreadExecutor(); + + private AnnotationConfigApplicationContext context; + + private CallState callState; + + @BeforeEach + void createContext() { + CallState.destroyed.clear(); + this.context = new AnnotationConfigApplicationContext(ScopeConfiguration.class); + this.callState = this.context.getBean(CallState.class); + } + + @AfterEach + void closeContext() { + this.context.close(); + this.worker.shutdownNow(); + } + + @Test + void scopedBeanIsCreatedPerCallAndDestroyedWhenTheCallCompletes() throws Exception { + ServerCallHandler handler = (call, headers) -> { + call.request(1); + return new ServerCall.Listener<>() { + @Override + public void onHalfClose() { + observedIds.add(callState.getId()); + observedIds.add(callState.getId()); + call.sendHeaders(new Metadata()); + call.sendMessage(Empty.getDefaultInstance()); + call.close(Status.OK, new Metadata()); + } + }; + }; + withServer(handler, (channel) -> { + CallOptions options = CallOptions.DEFAULT.withDeadlineAfter(5, TimeUnit.SECONDS); + ClientCalls.blockingUnaryCall(channel, METHOD, options, Empty.getDefaultInstance()); + ClientCalls.blockingUnaryCall(channel, METHOD, options, Empty.getDefaultInstance()); + }); + assertThat(this.observedIds).hasSize(4); + assertThat(this.observedIds.get(1)).isEqualTo(this.observedIds.get(0)); + assertThat(this.observedIds.get(3)).isEqualTo(this.observedIds.get(2)).isNotEqualTo(this.observedIds.get(0)); + Awaitility.await() + .atMost(Duration.ofSeconds(5)) + .untilAsserted(() -> assertThat(CallState.destroyed).containsExactlyInAnyOrder(this.observedIds.get(0), + this.observedIds.get(2))); + } + + @Test + void scopedBeanIsDestroyedWhenTheCallIsCancelled() throws Exception { + ServerCallHandler handler = (call, headers) -> { + call.request(1); + return new ServerCall.Listener<>() { + @Override + public void onHalfClose() { + observedIds.add(callState.getId()); + // never respond, so the client's deadline cancels the call + } + }; + }; + withServer(handler, (channel) -> { + CallOptions options = CallOptions.DEFAULT.withDeadlineAfter(200, TimeUnit.MILLISECONDS); + assertThatThrownBy( + () -> ClientCalls.blockingUnaryCall(channel, METHOD, options, Empty.getDefaultInstance())) + .isInstanceOf(StatusRuntimeException.class); + }); + assertThat(this.observedIds).hasSize(1); + Awaitility.await() + .atMost(Duration.ofSeconds(5)) + .untilAsserted(() -> assertThat(CallState.destroyed).containsExactly(this.observedIds.get(0))); + } + + @Test + void scopedBeanIsSharedWithAThreadTheCallHandsWorkTo() throws Exception { + ServerCallHandler handler = (call, headers) -> { + call.request(1); + return new ServerCall.Listener<>() { + @Override + public void onHalfClose() { + observedIds.add(callState.getId()); + try { + worker.submit(Context.current().wrap(() -> observedIds.add(callState.getId()))) + .get(5, TimeUnit.SECONDS); + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + call.sendHeaders(new Metadata()); + call.sendMessage(Empty.getDefaultInstance()); + call.close(Status.OK, new Metadata()); + } + }; + }; + withServer(handler, (channel) -> { + CallOptions options = CallOptions.DEFAULT.withDeadlineAfter(5, TimeUnit.SECONDS); + ClientCalls.blockingUnaryCall(channel, METHOD, options, Empty.getDefaultInstance()); + }); + assertThat(this.observedIds).hasSize(2); + assertThat(this.observedIds.get(1)).isEqualTo(this.observedIds.get(0)); + // Outside of the call, the worker thread has no scope either + assertThatThrownBy(() -> this.worker.submit(this.callState::getId).get(5, TimeUnit.SECONDS)) + .hasCauseInstanceOf(ScopeNotActiveException.class); + } + + @Test + void scopeIsOpenForTheOtherGlobalInterceptors() throws Exception { + ServerCallHandler handler = (call, headers) -> { + call.request(1); + return new ServerCall.Listener<>() { + @Override + public void onHalfClose() { + observedIds.add(callState.getId()); + call.sendHeaders(new Metadata()); + call.sendMessage(Empty.getDefaultInstance()); + call.close(Status.OK, new Metadata()); + } + }; + }; + BindableService service = () -> ServerServiceDefinition.builder(SERVICE_NAME) + .addMethod(METHOD, handler) + .build(); + DefaultGrpcServiceConfigurer configurer = new DefaultGrpcServiceConfigurer(this.context); + configurer.afterPropertiesSet(); + ServerServiceDefinition configured = configurer.configure(new GrpcServiceSpec(service, null), null); + withService(configured, (channel) -> { + CallOptions options = CallOptions.DEFAULT.withDeadlineAfter(5, TimeUnit.SECONDS); + ClientCalls.blockingUnaryCall(channel, METHOD, options, Empty.getDefaultInstance()); + }); + ScopeProbeInterceptor probe = this.context.getBean(ScopeProbeInterceptor.class); + assertThat(probe.seenIds).containsExactlyElementsOf(this.observedIds); + } + + @Test + void enableGrpcScopeRegistersTheInterceptorAsTheFirstGlobalInterceptor() { + List globalInterceptors = ApplicationContextBeanLookupUtils + .getBeansWithAnnotation(this.context, ServerInterceptor.class, GlobalServerInterceptor.class); + assertThat(globalInterceptors).hasSize(2); + assertThat(globalInterceptors.get(0)).isInstanceOf(GrpcRequestScopeServerInterceptor.class); + } + + @Test + void scopeIsNotActiveOutsideOfACall() { + assertThatThrownBy(this.callState::getId).isInstanceOf(ScopeNotActiveException.class); + } + + private void withServer(ServerCallHandler handler, ChannelCallback callback) throws Exception { + ServerServiceDefinition service = ServerServiceDefinition.builder(SERVICE_NAME) + .addMethod(METHOD, handler) + .build(); + GrpcRequestScopeServerInterceptor scope = this.context.getBean(GrpcRequestScopeServerInterceptor.class); + withService(ServerInterceptors.interceptForward(service, scope), callback); + } + + private void withService(ServerServiceDefinition service, ChannelCallback callback) throws Exception { + String serverName = InProcessServerBuilder.generateName(); + Server server = InProcessServerBuilder.forName(serverName).directExecutor().addService(service).build().start(); + ManagedChannel channel = InProcessChannelBuilder.forName(serverName).directExecutor().build(); + try { + callback.call(channel); + } + finally { + channel.shutdownNow(); + server.shutdownNow(); + } + } + + @FunctionalInterface + private interface ChannelCallback { + + void call(ManagedChannel channel) throws Exception; + + } + + @Configuration(proxyBeanMethods = false) + @EnableGrpcScope + static class ScopeConfiguration { + + @Bean + @GlobalServerInterceptor + ScopeProbeInterceptor scopeProbeInterceptor(CallState callState) { + return new ScopeProbeInterceptor(callState); + } + + @Bean + @GrpcRequestScope + CallState callState() { + return new CallState(); + } + + } + + /** + * A global interceptor with the default (lowest) precedence that reads the scoped + * bean of the call it intercepts. + */ + static class ScopeProbeInterceptor implements ServerInterceptor { + + final List seenIds = new CopyOnWriteArrayList<>(); + + private final CallState callState; + + ScopeProbeInterceptor(CallState callState) { + this.callState = callState; + } + + @Override + public ServerCall.Listener interceptCall(ServerCall call, Metadata headers, + ServerCallHandler next) { + this.seenIds.add(this.callState.getId()); + return next.startCall(call, headers); + } + + } + + static class CallState implements DisposableBean { + + static final List destroyed = new CopyOnWriteArrayList<>(); + + private final String id = UUID.randomUUID().toString(); + + public String getId() { + return this.id; + } + + @Override + public void destroy() { + destroyed.add(this.id); + } + + } + +} diff --git a/spring-grpc-docs/src/main/antora/modules/ROOT/pages/server.adoc b/spring-grpc-docs/src/main/antora/modules/ROOT/pages/server.adoc index 03b32d9a..ff36eff2 100644 --- a/spring-grpc-docs/src/main/antora/modules/ROOT/pages/server.adoc +++ b/spring-grpc-docs/src/main/antora/modules/ROOT/pages/server.adoc @@ -157,6 +157,32 @@ However, by setting the `blendWithGlobalInterceptors` attribute on the javadoc:o You can use this option if you want to add a per-service interceptor between global interceptors. ==== +[[request-scoped-beans]] +== Request Scoped Beans + +Beans can be scoped to a single gRPC call with the javadoc:org.springframework.grpc.server.annotation.GrpcRequestScope[format=annotation] annotation. +A bean in this scope is created once per call on first use, is shared by every thread to which the call's `io.grpc.Context` is propagated, and is destroyed when the call completes or is cancelled. + +To activate the scope, add javadoc:org.springframework.grpc.server.scope.EnableGrpcScope[format=annotation] to a configuration class. +It registers the `grpcRequest` scope with the bean factory and a global server interceptor that opens a new scope for every call, ahead of the other interceptors in the service chain. + +[source,java] +---- +@Configuration +@EnableGrpcScope +class ScopeConfiguration { + + @Bean + @GrpcRequestScope + CallContext callContext() { + return new CallContext(); + } + +} +---- + +NOTE: Accessing a request scoped bean outside of a call, or from a thread to which the call's `io.grpc.Context` has not been propagated, fails with a `ScopeNotActiveException`. + [[reflection-service]] == Reflection diff --git a/spring-grpc-docs/src/main/antora/modules/ROOT/pages/whats-new.adoc b/spring-grpc-docs/src/main/antora/modules/ROOT/pages/whats-new.adoc index 2b5c030c..ec27fb5e 100644 --- a/spring-grpc-docs/src/main/antora/modules/ROOT/pages/whats-new.adoc +++ b/spring-grpc-docs/src/main/antora/modules/ROOT/pages/whats-new.adoc @@ -8,6 +8,7 @@ This section covers the changes made from version 1.0.0 to version 1.1.2-SNAPSHOT. * Spring Boot autoconfiguration and starters were moved to Spring Boot - see the https://github.com/spring-projects/spring-grpc/wiki/Spring-gRPC-1.1-Migration-Guide[migration guide] for more details +* Beans can be scoped to a gRPC call with `@GrpcRequestScope`, activated with `@EnableGrpcScope` - see xref:server.adoc#request-scoped-beans[Request Scoped Beans] [[what-s-new-in-1.0.0-since-0-9-0]] == What's New in 1.0.0 Since 0.9.0