From 5e400b828641ca5b552f785f95863eea8acdfafc Mon Sep 17 00:00:00 2001 From: Quinn Klassen Date: Tue, 20 Oct 2026 10:17:21 -0700 Subject: [PATCH 1/3] Add Spring Boot auto-configuration for ActivityClient --- CHANGELOG.md | 2 + temporal-spring-boot-autoconfigure/README.md | 60 ++++++ .../NonRootBeanPostProcessor.java | 11 +- .../RootNamespaceAutoConfiguration.java | 21 ++- .../TestServerAutoConfiguration.java | 4 +- .../NonRootNamespaceProperties.java | 8 + .../template/ClientTemplate.java | 59 +++--- .../template/NamespaceTemplate.java | 16 +- .../template/NonRootNamespaceTemplate.java | 53 +----- .../WorkflowClientOptionsTemplate.java | 55 +++--- .../ActivityClientAutoConfigurationTest.java | 178 ++++++++++++++++++ .../boot/autoconfigure/ClientOnlyTest.java | 9 + .../autoconfigure/MultiNamespaceTest.java | 2 + 13 files changed, 372 insertions(+), 106 deletions(-) create mode 100644 temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/ActivityClientAutoConfigurationTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index dbc9d1e623..75e0da8ac7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ to docs, or any other relevant information. `WorkflowOutboundCallsInterceptorBase` need to add them, and Base subclasses override them to see calls with options. ### Added +- Spring Boot now auto-configures `ActivityClient` for root and non-root namespaces, with namespace-specific + data converters, options customization, and root activity client interceptor registration. - Applications can use the experimental `@TransferTypeConvertible` annotation and `TransferTypeConverter` interface to convert top-level model values to transfer types. The SDK applies this conversion to workflow, activity, Nexus, and schedule payloads, including failure diff --git a/temporal-spring-boot-autoconfigure/README.md b/temporal-spring-boot-autoconfigure/README.md index a8dc71c98a..209a7ec198 100644 --- a/temporal-spring-boot-autoconfigure/README.md +++ b/temporal-spring-boot-autoconfigure/README.md @@ -2,6 +2,66 @@ For documentation on the Temporal Spring Boot Integration, please visit [https://docs.temporal.io/develop/java/spring-boot-integration](https://docs.temporal.io/develop/java/spring-boot-integration) +# Standalone Activity Client + +Spring Boot auto-configures a primary `ActivityClient` bean named `temporalActivityClient` for the root namespace. +Each non-root namespace has a bean named `ActivityClient`, using its alias when configured, +otherwise its namespace. Each client uses its namespace's data converter and the same service stubs and connection +as its `ScheduleClient`. + +For example, configure a connection and an additional namespace: + +```yml +spring.temporal: + namespace: default + connection: + target: localhost:7233 + namespaces: + - namespace: assign + alias: assign +``` + +Inject the root or a named non-root client and execute a standalone activity: + +```java +import io.temporal.client.ActivityClient; +import io.temporal.client.StartActivityOptions; +import java.time.Duration; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; + +@Service +public class StandaloneActivities { + private final ActivityClient rootClient; + private final ActivityClient assignClient; + + public StandaloneActivities( + ActivityClient rootClient, + @Qualifier("assignActivityClient") ActivityClient assignClient) { + this.rootClient = rootClient; + this.assignClient = assignClient; + } + + public String greet(String name) { + StartActivityOptions options = StartActivityOptions.newBuilder() + .setId(UUID.randomUUID().toString()) + .setTaskQueue("greetings") + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .build(); + return rootClient.execute("Greet", String.class, options, name); + } +} +``` + +Use `TemporalOptionsCustomizer` beans to customize the root client; multiple +customizers are applied in Spring order (for example, using `@Order`). For a non-root namespace, name its +customizer `ActivityClientCustomizer`, such as `assignActivityClientCustomizer`. +`ActivityClientInterceptor` beans are automatically registered only on the root client, just like schedule +client interceptors. Set non-root interceptors through that namespace's activity client options customizer. + +Standalone activities are not supported by the test server. + # Running Multiple Name Space (experimental) Along with the root namespace, you can configure multiple non-root namespaces in the application.yml file. Different namespaces can have different configurations including but not limited to different connection options, registered workflows/activities, data converters etc. diff --git a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/NonRootBeanPostProcessor.java b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/NonRootBeanPostProcessor.java index 2aeef3b18d..4bff69a463 100644 --- a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/NonRootBeanPostProcessor.java +++ b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/NonRootBeanPostProcessor.java @@ -3,6 +3,8 @@ import com.google.common.base.MoreObjects; import com.uber.m3.tally.Scope; import io.opentracing.Tracer; +import io.temporal.client.ActivityClient; +import io.temporal.client.ActivityClientOptions; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowClientOptions; import io.temporal.client.WorkflowClientPlugin; @@ -134,6 +136,8 @@ private void injectBeanByNonRootNamespace(NonRootNamespaceProperties ns) { } List> scheduleClientCustomizers = findBeanByNameSpaceForTemporalCustomizer(beanPrefix, ScheduleClientOptions.Builder.class); + List> activityClientCustomizers = + findBeanByNameSpaceForTemporalCustomizer(beanPrefix, ActivityClientOptions.Builder.class); List> workflowImplementationCustomizers = findBeanByNameSpaceForTemporalCustomizer( @@ -169,11 +173,14 @@ private void injectBeanByNonRootNamespace(NonRootNamespaceProperties ns) { workflowImplementationCustomizers, workflowClientPlugins, scheduleClientPlugins, - workerPlugins); + workerPlugins, + null, + activityClientCustomizers); ClientTemplate clientTemplate = namespaceTemplate.getClientTemplate(); WorkflowClient workflowClient = clientTemplate.getWorkflowClient(); ScheduleClient scheduleClient = clientTemplate.getScheduleClient(); + ActivityClient activityClient = clientTemplate.getActivityClient(); WorkersTemplate workersTemplate = namespaceTemplate.getWorkersTemplate(); WorkerFactory workerFactory = workersTemplate.getWorkerFactory(); @@ -192,6 +199,8 @@ private void injectBeanByNonRootNamespace(NonRootNamespaceProperties ns) { beanPrefix + WorkflowClient.class.getSimpleName(), workflowClient); beanFactory.registerSingleton( beanPrefix + ScheduleClient.class.getSimpleName(), scheduleClient); + beanFactory.registerSingleton( + beanPrefix + ActivityClient.class.getSimpleName(), activityClient); beanFactory.registerSingleton(beanPrefix + WorkerFactory.class.getSimpleName(), workerFactory); } diff --git a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/RootNamespaceAutoConfiguration.java b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/RootNamespaceAutoConfiguration.java index 9ff3908bb5..4d921df1ab 100644 --- a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/RootNamespaceAutoConfiguration.java +++ b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/RootNamespaceAutoConfiguration.java @@ -1,6 +1,8 @@ package io.temporal.spring.boot.autoconfigure; import io.opentracing.Tracer; +import io.temporal.client.ActivityClient; +import io.temporal.client.ActivityClientOptions; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowClientOptions; import io.temporal.client.WorkflowClientPlugin; @@ -8,6 +10,7 @@ import io.temporal.client.schedules.ScheduleClientOptions; import io.temporal.client.schedules.ScheduleClientPlugin; import io.temporal.common.converter.DataConverter; +import io.temporal.common.interceptors.ActivityClientInterceptor; import io.temporal.common.interceptors.ScheduleClientInterceptor; import io.temporal.common.interceptors.WorkerInterceptor; import io.temporal.common.interceptors.WorkflowClientInterceptor; @@ -75,6 +78,8 @@ public NamespaceTemplate rootNamespaceTemplate( List workflowClientInterceptors, @Autowired(required = false) @Nullable List scheduleClientInterceptors, + @Autowired(required = false) @Nullable + List activityClientInterceptors, @Autowired(required = false) @Nullable List workerInterceptors, @Qualifier("temporalTestWorkflowEnvironmentAdapter") @Autowired(required = false) @Nullable TestWorkflowEnvironmentAdapter testWorkflowEnvironment, @@ -88,6 +93,9 @@ public NamespaceTemplate rootNamespaceTemplate( @Autowired(required = false) @Nullable Map> scheduleCustomizerMap, + @Autowired(required = false) @Nullable + Map> + activityCustomizerMap, @Autowired(required = false) @Nullable Map> workflowImplementationCustomizerMap, @@ -119,6 +127,9 @@ public NamespaceTemplate rootNamespaceTemplate( List> scheduleCustomizer = AutoConfigurationUtils.chooseTemporalCustomizerBeans( beanFactory, scheduleCustomizerMap, ScheduleClientOptions.Builder.class, properties); + List> activityCustomizer = + AutoConfigurationUtils.chooseTemporalCustomizerBeans( + beanFactory, activityCustomizerMap, ActivityClientOptions.Builder.class, properties); List> workflowImplementationCustomizer = AutoConfigurationUtils.chooseTemporalCustomizerBeans( @@ -166,7 +177,9 @@ public NamespaceTemplate rootNamespaceTemplate( workflowImplementationCustomizer, filteredClientPlugins, filteredSchedulePlugins, - filteredWorkerPlugins); + filteredWorkerPlugins, + activityClientInterceptors, + activityCustomizer); } /** Client */ @@ -189,6 +202,12 @@ public ScheduleClient scheduleClient(ClientTemplate clientTemplate) { return clientTemplate.getScheduleClient(); } + @Primary + @Bean(name = "temporalActivityClient") + public ActivityClient activityClient(ClientTemplate clientTemplate) { + return clientTemplate.getActivityClient(); + } + /** Workers */ @Primary @Bean(name = "temporalWorkersTemplate") diff --git a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/TestServerAutoConfiguration.java b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/TestServerAutoConfiguration.java index 97f712d483..e17fc182a3 100644 --- a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/TestServerAutoConfiguration.java +++ b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/TestServerAutoConfiguration.java @@ -149,7 +149,9 @@ public TestWorkflowEnvironment testWorkflowEnvironment( clientCustomizer, scheduleCustomizer, filteredClientPlugins, - filteredSchedulePlugins) + filteredSchedulePlugins, + null, + null) .createWorkflowClientOptions()); if (metricsScope != null) { diff --git a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/properties/NonRootNamespaceProperties.java b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/properties/NonRootNamespaceProperties.java index a43f0c1a4f..11c2386509 100644 --- a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/properties/NonRootNamespaceProperties.java +++ b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/properties/NonRootNamespaceProperties.java @@ -16,10 +16,18 @@ public class NonRootNamespaceProperties extends NamespaceProperties { * - WorkersTemplate
* - WorkflowClient
* - ScheduleClient
+ * - ActivityClient
* - WorkerFactory
* You guys can use this alias to get the beans.
* for example if you set spring.temporal.namespace[0].alias=foo
* We can get bean via @Autowired @Qualifier("fooNamespaceTemplate") NamespaceTemplate + * + *

The standalone activity client bean is named {@code ActivityClient}, for + * example {@code fooActivityClient}. It uses the namespace's data converter and the same service + * stubs and connection as its schedule client. Customize its options with a {@code + * TemporalOptionsCustomizer} bean named {@code + * fooActivityClientCustomizer}; activity client interceptors for non-root namespaces must also be + * configured through this customizer. */ private final @Nullable String alias; diff --git a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/ClientTemplate.java b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/ClientTemplate.java index c9f68cb620..04f83edfa1 100644 --- a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/ClientTemplate.java +++ b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/ClientTemplate.java @@ -2,6 +2,8 @@ import com.google.common.base.Preconditions; import io.opentracing.Tracer; +import io.temporal.client.ActivityClient; +import io.temporal.client.ActivityClientOptions; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowClientOptions; import io.temporal.client.WorkflowClientPlugin; @@ -9,6 +11,7 @@ import io.temporal.client.schedules.ScheduleClientOptions; import io.temporal.client.schedules.ScheduleClientPlugin; import io.temporal.common.converter.DataConverter; +import io.temporal.common.interceptors.ActivityClientInterceptor; import io.temporal.common.interceptors.ScheduleClientInterceptor; import io.temporal.common.interceptors.WorkflowClientInterceptor; import io.temporal.serviceclient.WorkflowServiceStubs; @@ -25,6 +28,7 @@ public class ClientTemplate { private WorkflowClient workflowClient; private ScheduleClient scheduleClient; + private ActivityClient activityClient; public ClientTemplate( @Nonnull String namespace, @@ -37,7 +41,10 @@ public ClientTemplate( @Nullable List> clientCustomizers, @Nullable List> scheduleCustomizers, @Nullable List workflowClientPlugins, - @Nullable List scheduleClientPlugins) { + @Nullable List scheduleClientPlugins, + @Nullable List activityClientInterceptors, + @Nullable + List> activityCustomizers) { this.optionsTemplate = new WorkflowClientOptionsTemplate( namespace, @@ -48,40 +55,13 @@ public ClientTemplate( clientCustomizers, scheduleCustomizers, workflowClientPlugins, - scheduleClientPlugins); + scheduleClientPlugins, + activityClientInterceptors, + activityCustomizers); this.workflowServiceStubs = workflowServiceStubs; this.testWorkflowEnvironment = testWorkflowEnvironment; } - /** - * @deprecated Use constructor with plugins parameters - */ - @Deprecated - public ClientTemplate( - @Nonnull String namespace, - @Nullable DataConverter dataConverter, - @Nullable List workflowClientInterceptors, - @Nullable List scheduleClientInterceptors, - @Nullable Tracer tracer, - @Nullable WorkflowServiceStubs workflowServiceStubs, - @Nullable TestWorkflowEnvironmentAdapter testWorkflowEnvironment, - @Nullable List> clientCustomizers, - @Nullable - List> scheduleCustomizers) { - this( - namespace, - dataConverter, - workflowClientInterceptors, - scheduleClientInterceptors, - tracer, - workflowServiceStubs, - testWorkflowEnvironment, - clientCustomizers, - scheduleCustomizers, - null, - null); - } - public WorkflowClient getWorkflowClient() { if (workflowClient == null) { this.workflowClient = createWorkflowClient(); @@ -96,6 +76,13 @@ public ScheduleClient getScheduleClient() { return scheduleClient; } + public ActivityClient getActivityClient() { + if (activityClient == null) { + this.activityClient = createActivityClient(); + } + return activityClient; + } + private WorkflowClient createWorkflowClient() { if (testWorkflowEnvironment != null) { return testWorkflowEnvironment.getWorkflowClient(); @@ -118,4 +105,14 @@ private ScheduleClient createScheduleClient() { workflowServiceStubs, optionsTemplate.createScheduleClientOptions()); } } + + private ActivityClient createActivityClient() { + WorkflowServiceStubs stubs = workflowServiceStubs; + if (testWorkflowEnvironment != null) { + stubs = testWorkflowEnvironment.getWorkflowClient().getWorkflowServiceStubs(); + } + Preconditions.checkState( + stubs != null, "ClientTemplate was created without workflowServiceStubs"); + return ActivityClient.newInstance(stubs, optionsTemplate.createActivityClientOptions()); + } } diff --git a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/NamespaceTemplate.java b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/NamespaceTemplate.java index f8966d6ac2..61c31c2753 100644 --- a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/NamespaceTemplate.java +++ b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/NamespaceTemplate.java @@ -1,11 +1,13 @@ package io.temporal.spring.boot.autoconfigure.template; import io.opentracing.Tracer; +import io.temporal.client.ActivityClientOptions; import io.temporal.client.WorkflowClientOptions; import io.temporal.client.WorkflowClientPlugin; import io.temporal.client.schedules.ScheduleClientOptions; import io.temporal.client.schedules.ScheduleClientPlugin; import io.temporal.common.converter.DataConverter; +import io.temporal.common.interceptors.ActivityClientInterceptor; import io.temporal.common.interceptors.ScheduleClientInterceptor; import io.temporal.common.interceptors.WorkerInterceptor; import io.temporal.common.interceptors.WorkflowClientInterceptor; @@ -42,6 +44,9 @@ public class NamespaceTemplate { private final @Nullable List workflowClientPlugins; private final @Nullable List scheduleClientPlugins; private final @Nullable List workerPlugins; + private final @Nullable List activityClientInterceptors; + private final @Nullable List> + activityCustomizers; private ClientTemplate clientTemplate; private WorkersTemplate workersTemplate; @@ -65,7 +70,10 @@ public NamespaceTemplate( workflowImplementationCustomizers, @Nullable List workflowClientPlugins, @Nullable List scheduleClientPlugins, - @Nullable List workerPlugins) { + @Nullable List workerPlugins, + @Nullable List activityClientInterceptors, + @Nullable + List> activityCustomizers) { this.namespaceProperties = namespaceProperties; this.workflowServiceStubs = workflowServiceStubs; this.dataConverter = dataConverter; @@ -83,6 +91,8 @@ public NamespaceTemplate( this.workflowClientPlugins = workflowClientPlugins; this.scheduleClientPlugins = scheduleClientPlugins; this.workerPlugins = workerPlugins; + this.activityClientInterceptors = activityClientInterceptors; + this.activityCustomizers = activityCustomizers; } public ClientTemplate getClientTemplate() { @@ -99,7 +109,9 @@ public ClientTemplate getClientTemplate() { clientCustomizers, scheduleCustomizers, workflowClientPlugins, - scheduleClientPlugins); + scheduleClientPlugins, + activityClientInterceptors, + activityCustomizers); } return clientTemplate; } diff --git a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/NonRootNamespaceTemplate.java b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/NonRootNamespaceTemplate.java index 7f62bf97bb..8a849cf49d 100644 --- a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/NonRootNamespaceTemplate.java +++ b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/NonRootNamespaceTemplate.java @@ -1,11 +1,13 @@ package io.temporal.spring.boot.autoconfigure.template; import io.opentracing.Tracer; +import io.temporal.client.ActivityClientOptions; import io.temporal.client.WorkflowClientOptions; import io.temporal.client.WorkflowClientPlugin; import io.temporal.client.schedules.ScheduleClientOptions; import io.temporal.client.schedules.ScheduleClientPlugin; import io.temporal.common.converter.DataConverter; +import io.temporal.common.interceptors.ActivityClientInterceptor; import io.temporal.common.interceptors.ScheduleClientInterceptor; import io.temporal.common.interceptors.WorkerInterceptor; import io.temporal.common.interceptors.WorkflowClientInterceptor; @@ -46,7 +48,10 @@ public NonRootNamespaceTemplate( workflowImplementationCustomizers, @Nullable List workflowClientPlugins, @Nullable List scheduleClientPlugins, - @Nullable List workerPlugins) { + @Nullable List workerPlugins, + @Nullable List activityClientInterceptors, + @Nullable + List> activityCustomizers) { super( namespaceProperties, workflowServiceStubs, @@ -63,52 +68,12 @@ public NonRootNamespaceTemplate( workflowImplementationCustomizers, workflowClientPlugins, scheduleClientPlugins, - workerPlugins); + workerPlugins, + activityClientInterceptors, + activityCustomizers); this.beanFactory = beanFactory; } - /** - * @deprecated Use constructor with plugins parameters - */ - @Deprecated - public NonRootNamespaceTemplate( - @Nonnull BeanFactory beanFactory, - @Nonnull NonRootNamespaceProperties namespaceProperties, - @Nonnull WorkflowServiceStubs workflowServiceStubs, - @Nullable DataConverter dataConverter, - @Nullable List workflowClientInterceptors, - @Nullable List scheduleClientInterceptors, - @Nullable List workerInterceptors, - @Nullable Tracer tracer, - @Nullable TestWorkflowEnvironmentAdapter testWorkflowEnvironment, - @Nullable - List> workerFactoryCustomizers, - @Nullable List> workerCustomizers, - @Nullable List> clientCustomizers, - @Nullable List> scheduleCustomizers, - @Nullable - List> - workflowImplementationCustomizers) { - this( - beanFactory, - namespaceProperties, - workflowServiceStubs, - dataConverter, - workflowClientInterceptors, - scheduleClientInterceptors, - workerInterceptors, - tracer, - testWorkflowEnvironment, - workerFactoryCustomizers, - workerCustomizers, - clientCustomizers, - scheduleCustomizers, - workflowImplementationCustomizers, - null, - null, - null); - } - @Override public WorkersTemplate getWorkersTemplate() { WorkersTemplate workersTemplate = super.getWorkersTemplate(); diff --git a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/WorkflowClientOptionsTemplate.java b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/WorkflowClientOptionsTemplate.java index ca4e564577..17cb350ff8 100644 --- a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/WorkflowClientOptionsTemplate.java +++ b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/WorkflowClientOptionsTemplate.java @@ -1,11 +1,13 @@ package io.temporal.spring.boot.autoconfigure.template; import io.opentracing.Tracer; +import io.temporal.client.ActivityClientOptions; import io.temporal.client.WorkflowClientOptions; import io.temporal.client.WorkflowClientPlugin; import io.temporal.client.schedules.ScheduleClientOptions; import io.temporal.client.schedules.ScheduleClientPlugin; import io.temporal.common.converter.DataConverter; +import io.temporal.common.interceptors.ActivityClientInterceptor; import io.temporal.common.interceptors.ScheduleClientInterceptor; import io.temporal.common.interceptors.WorkflowClientInterceptor; import io.temporal.opentracing.OpenTracingClientInterceptor; @@ -29,6 +31,9 @@ public class WorkflowClientOptionsTemplate { scheduleCustomizers; private final @Nullable List workflowClientPlugins; private final @Nullable List scheduleClientPlugins; + private final @Nullable List activityClientInterceptors; + private final @Nullable List> + activityCustomizers; public WorkflowClientOptionsTemplate( @Nonnull String namespace, @@ -39,7 +44,10 @@ public WorkflowClientOptionsTemplate( @Nullable List> clientCustomizers, @Nullable List> scheduleCustomizers, @Nullable List workflowClientPlugins, - @Nullable List scheduleClientPlugins) { + @Nullable List scheduleClientPlugins, + @Nullable List activityClientInterceptors, + @Nullable + List> activityCustomizers) { this.namespace = namespace; this.dataConverter = dataConverter; this.workflowClientInterceptors = workflowClientInterceptors; @@ -49,31 +57,8 @@ public WorkflowClientOptionsTemplate( this.scheduleCustomizers = scheduleCustomizers; this.workflowClientPlugins = workflowClientPlugins; this.scheduleClientPlugins = scheduleClientPlugins; - } - - /** - * @deprecated Use constructor with plugins parameters - */ - @Deprecated - public WorkflowClientOptionsTemplate( - @Nonnull String namespace, - @Nullable DataConverter dataConverter, - @Nullable List workflowClientInterceptors, - @Nullable List scheduleClientInterceptors, - @Nullable Tracer tracer, - @Nullable List> clientCustomizers, - @Nullable - List> scheduleCustomizers) { - this( - namespace, - dataConverter, - workflowClientInterceptors, - scheduleClientInterceptors, - tracer, - clientCustomizers, - scheduleCustomizers, - null, - null); + this.activityClientInterceptors = activityClientInterceptors; + this.activityCustomizers = activityCustomizers; } public WorkflowClientOptions createWorkflowClientOptions() { @@ -128,4 +113,22 @@ public ScheduleClientOptions createScheduleClientOptions() { return options.build(); } + + public ActivityClientOptions createActivityClientOptions() { + ActivityClientOptions.Builder options = + ActivityClientOptions.newBuilder().setNamespace(namespace); + Optional.ofNullable(dataConverter).ifPresent(options::setDataConverter); + if (activityClientInterceptors != null && !activityClientInterceptors.isEmpty()) { + options.setInterceptors(activityClientInterceptors); + } + + if (activityCustomizers != null) { + for (TemporalOptionsCustomizer customizer : + activityCustomizers) { + options = customizer.customize(options); + } + } + + return options.build(); + } } diff --git a/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/ActivityClientAutoConfigurationTest.java b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/ActivityClientAutoConfigurationTest.java new file mode 100644 index 0000000000..fbff89f83d --- /dev/null +++ b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/ActivityClientAutoConfigurationTest.java @@ -0,0 +1,178 @@ +package io.temporal.spring.boot.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import io.temporal.client.ActivityClient; +import io.temporal.client.ActivityClientOptions; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.common.interceptors.ActivityClientInterceptor; +import io.temporal.common.interceptors.ActivityClientInterceptorBase; +import io.temporal.spring.boot.TemporalOptionsCustomizer; +import io.temporal.spring.boot.autoconfigure.template.ClientTemplate; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.core.annotation.Order; + +class ActivityClientAutoConfigurationTest { + private final ApplicationContextRunner contextRunner = + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + TestServerAutoConfiguration.class, + ServiceStubsAutoConfiguration.class, + RootNamespaceAutoConfiguration.class, + NonRootNamespaceAutoConfiguration.class)) + .withPropertyValues( + "spring.temporal.connection.target=local", + "spring.temporal.namespace=root", + "spring.temporal.start-workers=false"); + + @Test + void createsClientWithoutOptionalConfiguration() { + contextRunner.run( + context -> { + assertThat(context).hasNotFailed().hasSingleBean(ActivityClient.class); + ActivityClient client = context.getBean(ActivityClient.class); + assertThat(client).isSameAs(context.getBean("temporalActivityClient")); + assertThat(client).isSameAs(context.getBean(ClientTemplate.class).getActivityClient()); + assertThat(client.getHandle("test", null)).isNotNull(); + }); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void appliesRootOptionsAndOrderedCustomizers(boolean testServer) { + contextRunner + .withUserConfiguration(ClientConfiguration.class) + .withPropertyValues("spring.temporal.test-server.enabled=" + testServer) + .run( + context -> { + assertThat(context).hasNotFailed().hasSingleBean(ActivityClient.class); + ActivityClientOptions options = context.getBean(CapturedOptions.class).get("root"); + assertThat(options.getNamespace()).isEqualTo("root"); + assertThat(options.getIdentity()).isEqualTo("last"); + assertThat(options.getDataConverter()).isSameAs(context.getBean(DataConverter.class)); + ActivityClientInterceptor interceptor = + context.getBean(ActivityClientInterceptor.class); + assertThat(options.getInterceptors()).containsExactly(interceptor); + verify(interceptor).activityClientCallsInterceptor(any()); + }); + } + + @Test + void appliesNonRootOptionsByAliasOrNamespace() { + contextRunner + .withUserConfiguration(ClientConfiguration.class, NonRootConfiguration.class) + .withPropertyValues( + "spring.temporal.namespaces[0].namespace=namespace1", + "spring.temporal.namespaces[0].alias=ns1", + "spring.temporal.namespaces[1].namespace=namespace2") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBeansOfType(ActivityClient.class)) + .containsOnlyKeys( + "temporalActivityClient", "ns1ActivityClient", "namespace2ActivityClient"); + assertThat(context.getBean(ActivityClient.class)) + .isSameAs(context.getBean("temporalActivityClient")); + CapturedOptions captured = context.getBean(CapturedOptions.class); + assertThat(captured.get("root").getNamespace()).isEqualTo("root"); + assertThat(captured.get("root").getIdentity()).isEqualTo("last"); + ActivityClientOptions ns1Options = captured.get("ns1"); + assertThat(ns1Options.getNamespace()).isEqualTo("namespace1"); + assertThat(ns1Options.getIdentity()).isEqualTo("ns1"); + assertThat(ns1Options.getDataConverter()) + .isSameAs(context.getBean("ns1DataConverter")); + assertThat(ns1Options.getInterceptors()).isEmpty(); + ActivityClientOptions ns2Options = captured.get("namespace2"); + assertThat(ns2Options.getNamespace()).isEqualTo("namespace2"); + assertThat(ns2Options.getIdentity()).isEqualTo("namespace2"); + assertThat(ns2Options.getDataConverter()) + .isSameAs(context.getBean("namespace2DataConverter")); + assertThat(ns2Options.getInterceptors()).isEmpty(); + verify(context.getBean(ActivityClientInterceptor.class)) + .activityClientCallsInterceptor(any()); + }); + } + + static class CapturedOptions { + private final Map options = new HashMap<>(); + + ActivityClientOptions get(String prefix) { + return options.get(prefix); + } + + ActivityClientOptions.Builder capture(String prefix, ActivityClientOptions.Builder builder) { + options.put(prefix, builder.build()); + return builder; + } + } + + static class ClientConfiguration { + @Bean + CapturedOptions capturedOptions() { + return new CapturedOptions(); + } + + @Bean + DataConverter rootDataConverter() { + return DefaultDataConverter.newDefaultInstance(); + } + + @Bean + ActivityClientInterceptor activityClientInterceptor() { + return spy(new ActivityClientInterceptorBase()); + } + + @Bean + @Order(1) + TemporalOptionsCustomizer firstActivityClientCustomizer() { + return builder -> builder.setIdentity("first"); + } + + @Bean + @Order(2) + TemporalOptionsCustomizer lastActivityClientCustomizer( + CapturedOptions captured) { + return builder -> { + assertThat(builder.build().getIdentity()).isEqualTo("first"); + return captured.capture( + "root", ActivityClientOptions.newBuilder(builder.build()).setIdentity("last")); + }; + } + } + + static class NonRootConfiguration { + @Bean + DataConverter ns1DataConverter() { + return DefaultDataConverter.newDefaultInstance(); + } + + @Bean + DataConverter namespace2DataConverter() { + return DefaultDataConverter.newDefaultInstance(); + } + + @Bean + TemporalOptionsCustomizer ns1ActivityClientCustomizer( + CapturedOptions captured) { + return builder -> captured.capture("ns1", builder.setIdentity("ns1")); + } + + @Bean + TemporalOptionsCustomizer namespace2ActivityClientCustomizer( + CapturedOptions captured) { + return builder -> captured.capture("namespace2", builder.setIdentity("namespace2")); + } + } +} diff --git a/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/ClientOnlyTest.java b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/ClientOnlyTest.java index f4fcd34dca..e71067b403 100644 --- a/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/ClientOnlyTest.java +++ b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/ClientOnlyTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import io.grpc.health.v1.HealthCheckResponse; +import io.temporal.client.ActivityClient; import io.temporal.client.WorkflowClient; import io.temporal.client.schedules.ScheduleClient; import io.temporal.testing.TestWorkflowEnvironment; @@ -28,6 +29,8 @@ public class ClientOnlyTest { @Autowired ScheduleClient scheduleClient; + @Autowired ActivityClient activityClient; + @BeforeEach void setUp() { applicationContext.start(); @@ -47,6 +50,12 @@ public void testScheduleClientWiring() { assertNotNull(scheduleClient.getHandle("test")); } + @Test + public void testActivityClientWiring() { + assertNotNull(activityClient); + assertNotNull(activityClient.getHandle("test", null)); + } + @ComponentScan( excludeFilters = @ComponentScan.Filter( diff --git a/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/MultiNamespaceTest.java b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/MultiNamespaceTest.java index eb80003cf4..5d9df44e50 100644 --- a/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/MultiNamespaceTest.java +++ b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/MultiNamespaceTest.java @@ -45,6 +45,8 @@ public void shouldContainsNonRootRelatedBean() { Assertions.assertTrue(applicationContext.containsBean("namespace2WorkflowClient")); Assertions.assertTrue(applicationContext.containsBean("ns1ScheduleClient")); Assertions.assertTrue(applicationContext.containsBean("namespace2ScheduleClient")); + Assertions.assertTrue(applicationContext.containsBean("ns1ActivityClient")); + Assertions.assertTrue(applicationContext.containsBean("namespace2ActivityClient")); Assertions.assertTrue(applicationContext.containsBean("ns1WorkerFactory")); Assertions.assertTrue(applicationContext.containsBean("namespace2WorkerFactory")); From 79b87c4bb7e4b567dca1fc66ed8011c50146f986 Mon Sep 17 00:00:00 2001 From: Quinn Klassen Date: Tue, 20 Oct 2026 10:18:37 -0700 Subject: [PATCH 2/3] Remove redundant activity client property documentation --- .../properties/NonRootNamespaceProperties.java | 7 ------- 1 file changed, 7 deletions(-) diff --git a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/properties/NonRootNamespaceProperties.java b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/properties/NonRootNamespaceProperties.java index 11c2386509..9b82d879d5 100644 --- a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/properties/NonRootNamespaceProperties.java +++ b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/properties/NonRootNamespaceProperties.java @@ -21,13 +21,6 @@ public class NonRootNamespaceProperties extends NamespaceProperties { * You guys can use this alias to get the beans.
* for example if you set spring.temporal.namespace[0].alias=foo
* We can get bean via @Autowired @Qualifier("fooNamespaceTemplate") NamespaceTemplate - * - *

The standalone activity client bean is named {@code ActivityClient}, for - * example {@code fooActivityClient}. It uses the namespace's data converter and the same service - * stubs and connection as its schedule client. Customize its options with a {@code - * TemporalOptionsCustomizer} bean named {@code - * fooActivityClientCustomizer}; activity client interceptors for non-root namespaces must also be - * configured through this customizer. */ private final @Nullable String alias; From 04f208e740d578cb2dae660c234a4b45f7d5af43 Mon Sep 17 00:00:00 2001 From: Quinn Klassen Date: Fri, 30 Oct 2026 10:50:01 -0700 Subject: [PATCH 3/3] Auto-configure tracing for standalone activity clients --- .../WorkflowClientOptionsTemplate.java | 13 ++++++++-- .../ActivityClientAutoConfigurationTest.java | 24 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/WorkflowClientOptionsTemplate.java b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/WorkflowClientOptionsTemplate.java index 17cb350ff8..a6b57621a5 100644 --- a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/WorkflowClientOptionsTemplate.java +++ b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/WorkflowClientOptionsTemplate.java @@ -10,6 +10,7 @@ import io.temporal.common.interceptors.ActivityClientInterceptor; import io.temporal.common.interceptors.ScheduleClientInterceptor; import io.temporal.common.interceptors.WorkflowClientInterceptor; +import io.temporal.opentracing.OpenTracingActivityClientInterceptor; import io.temporal.opentracing.OpenTracingClientInterceptor; import io.temporal.opentracing.OpenTracingOptions; import io.temporal.spring.boot.TemporalOptionsCustomizer; @@ -118,9 +119,17 @@ public ActivityClientOptions createActivityClientOptions() { ActivityClientOptions.Builder options = ActivityClientOptions.newBuilder().setNamespace(namespace); Optional.ofNullable(dataConverter).ifPresent(options::setDataConverter); - if (activityClientInterceptors != null && !activityClientInterceptors.isEmpty()) { - options.setInterceptors(activityClientInterceptors); + + List interceptors = new ArrayList<>(); + if (tracer != null) { + interceptors.add( + new OpenTracingActivityClientInterceptor( + OpenTracingOptions.newBuilder().setTracer(tracer).build())); + } + if (activityClientInterceptors != null) { + interceptors.addAll(activityClientInterceptors); } + options.setInterceptors(interceptors); if (activityCustomizers != null) { for (TemporalOptionsCustomizer customizer : diff --git a/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/ActivityClientAutoConfigurationTest.java b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/ActivityClientAutoConfigurationTest.java index fbff89f83d..5abaac6327 100644 --- a/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/ActivityClientAutoConfigurationTest.java +++ b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/ActivityClientAutoConfigurationTest.java @@ -2,15 +2,18 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; +import io.opentracing.Tracer; import io.temporal.client.ActivityClient; import io.temporal.client.ActivityClientOptions; import io.temporal.common.converter.DataConverter; import io.temporal.common.converter.DefaultDataConverter; import io.temporal.common.interceptors.ActivityClientInterceptor; import io.temporal.common.interceptors.ActivityClientInterceptorBase; +import io.temporal.opentracing.OpenTracingActivityClientInterceptor; import io.temporal.spring.boot.TemporalOptionsCustomizer; import io.temporal.spring.boot.autoconfigure.template.ClientTemplate; import java.util.HashMap; @@ -69,6 +72,27 @@ void appliesRootOptionsAndOrderedCustomizers(boolean testServer) { }); } + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void registersTracingBeforeExplicitInterceptors(boolean testServer) { + contextRunner + .withUserConfiguration(ClientConfiguration.class) + .withBean(Tracer.class, () -> mock(Tracer.class)) + .withPropertyValues("spring.temporal.test-server.enabled=" + testServer) + .run( + context -> { + assertThat(context).hasNotFailed().hasSingleBean(ActivityClient.class); + ActivityClientOptions options = context.getBean(CapturedOptions.class).get("root"); + ActivityClientInterceptor interceptor = + context.getBean(ActivityClientInterceptor.class); + assertThat(options.getInterceptors()).hasSize(2); + assertThat(options.getInterceptors().get(0)) + .isInstanceOf(OpenTracingActivityClientInterceptor.class); + assertThat(options.getInterceptors().get(1)).isSameAs(interceptor); + verify(interceptor).activityClientCallsInterceptor(any()); + }); + } + @Test void appliesNonRootOptionsByAliasOrNamespace() { contextRunner