Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* 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;
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.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.core.annotation.AliasFor;

@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Scope("grpcRequest")
public @interface GrpcRequestScope {

@AliasFor(annotation = Scope.class)
ScopedProxyMode proxyMode() default ScopedProxyMode.TARGET_CLASS;

}
Original file line number Diff line number Diff line change
@@ -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 {

}
Original file line number Diff line number Diff line change
@@ -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<Map<String, Object>> threadLocalScope = ThreadLocal.withInitial(HashMap::new);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A ThreadLocal makes the scope per thread rather than per call. gRPC runs a call's listener callbacks on its executor, so successive callbacks of one call can land on different threads, and a service that hands work to another thread gets a different instance there: in my test, a lookup from a worker thread via Context.current().wrap(...) returned a second instance, and that instance was still returned on the worker thread after the call had completed, since clear() only runs on the completing thread. Keying the beans on the call's io.grpc.Context instead (a Context.Key, attached with Contexts.interceptCall(...) in the interceptor) gives one instance per call on every thread the context is propagated to; that is what the net.devh implementation does. Also, withInitial(HashMap::new) means a lookup outside a call quietly creates a bean in a thread-wide map instead of failing with ScopeNotActiveException like @RequestScope does; the test for that passed without throwing.


@Override
public Object get(String name, ObjectFactory<?> objectFactory) {
Map<String, Object> 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<String, Object> scope = threadLocalScope.get();
return scope.remove(name);
}

@Override
public void registerDestructionCallback(String name, Runnable callback) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scoped beans never get their @PreDestroy/DisposableBean callbacks (destroy() was not called after the call completed in my test): the callbacks registered here have to be run when the call ends. A Context listener fires on completion and cancellation alike, which also saves the two overrides in the interceptor.

}

@Override
public Object resolveContextualObject(String key) {
return null;
}

@Override
public String getConversationId() {
return null;
}

public static void clear() {
threadLocalScope.remove();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* 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 <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(ServerCall<ReqT, RespT> call, Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
ServerCall.Listener<ReqT> delegate = next.startCall(call, headers);
return new ForwardingServerCallListener.SimpleForwardingServerCallListener<ReqT>(delegate) {
@Override
public void onComplete() {
try {
super.onComplete();
}
finally {
GrpcRequestScope.clear();
}
}

@Override
public void onCancel() {
try {
super.onCancel();
}
finally {
GrpcRequestScope.clear();
}
}
};
}

}
Original file line number Diff line number Diff line change
@@ -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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defining a GrpcRequestScope bean does not register it as a scope: with @EnableGrpcScope alone, the first use of a @GrpcRequestScope bean fails with IllegalStateException: No Scope registered for scope name 'grpcRequest'. The registrar needs a CustomScopeConfigurer, or the interceptor can implement BeanFactoryPostProcessor and call registerScope.

}
if (!registry.containsBeanDefinition("grpcRequestScopeServerInterceptor")) {
registry.registerBeanDefinition("grpcRequestScopeServerInterceptor",
new RootBeanDefinition(GrpcRequestScopeServerInterceptor.class));
}
}

}
Original file line number Diff line number Diff line change
@@ -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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

containsBean never resolves the proxy, so this passes without the scope being registered. A test that makes an in-process call (InProcessServerBuilder is already a test dependency), reads the bean twice within it and once from a worker thread, and checks the instance is destroyed afterwards would cover the behaviour; the PR linked above has one you are welcome to take.

}
}

@Configuration
@EnableGrpcScope
static class TestConfig {

@Bean
@GrpcRequestScope
public TestBean testBean() {
return new TestBean();
}

}

static class TestBean {

}

}
Loading