Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
583962b
WIP Transfer type converters
VegetarianOrc Sep 1, 2026
3b2bbcf
Handle missing transfer result types
VegetarianOrc Sep 2, 2026
1ff8771
Test transfer type converters across SDK paths
VegetarianOrc Sep 5, 2026
f50ffdd
Add transfer type support to workflow streams
VegetarianOrc Sep 14, 2026
3cea60a
Merge branch 'main' into amazzeo/transfer-type-converters
VegetarianOrc Sep 14, 2026
1c5fe58
Merge branch 'main' into amazzeo/transfer-type-converters
VegetarianOrc Sep 14, 2026
452f46c
Use transfer-type converter internally
VegetarianOrc Sep 16, 2026
6fec25d
Merge branch 'main' into amazzeo/transfer-type-converters
VegetarianOrc Sep 16, 2026
37411ad
Merge branch 'main' into amazzeo/transfer-type-converters
VegetarianOrc Oct 5, 2026
14e91b5
Remove transfer-type converters from workflow streams
VegetarianOrc Oct 5, 2026
4849ec0
Consolidate client data converter creation into factory
VegetarianOrc Oct 8, 2026
8be2665
Separate configured and internal client converters
VegetarianOrc Oct 8, 2026
030d5eb
Merge branch 'main' into amazzeo/transfer-type-converters
VegetarianOrc Oct 8, 2026
58bd31b
Validate transfer converter results during argument decoding
VegetarianOrc Oct 9, 2026
0b82638
Expose transfer-aware conversion in clients and failures
VegetarianOrc Oct 9, 2026
49b483b
Document transfer type converters in changelog
VegetarianOrc Oct 9, 2026
96eb2de
Test transfer-aware converter in activity context
VegetarianOrc Oct 9, 2026
aa6daac
Address transfer type converter review findings
VegetarianOrc Oct 12, 2026
184a740
Merge branch 'main' into amazzeo/transfer-type-converters
VegetarianOrc Oct 12, 2026
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ to docs, or any other relevant information.
## [Unreleased]

### Added
- 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
details. `WorkflowClient.getOptions().getDataConverter()` returns the transfer-aware converter
that the client uses.
- Added experimental `ChildWorkflowOptions.Builder.setVersioningOverride` and
`VersioningOverride.OneTimeVersioningOverride` for explicit pinned, auto-upgrade, and one-time
child workflow routing. Invalid child overrides are reported as `InvalidVersioningOverrideFailure`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
import com.uber.m3.tally.Scope;
import io.temporal.api.common.v1.Payload;
import io.temporal.common.context.ContextPropagator;
import io.temporal.common.converter.DataConverter;
import io.temporal.common.interceptors.ActivityClientCallsInterceptor;
import io.temporal.common.interceptors.ActivityClientInterceptor;
import io.temporal.common.interceptors.Header;
import io.temporal.internal.client.ActivityClientInternal;
import io.temporal.internal.client.ActivityHandleImpl;
import io.temporal.internal.client.ClientDataConverterFactory;
import io.temporal.internal.client.RootActivityClientInvoker;
import io.temporal.internal.client.external.GenericWorkflowClientImpl;
import io.temporal.internal.client.external.ManualActivityCompletionClientFactory;
Expand Down Expand Up @@ -37,20 +39,25 @@ class ActivityClientImpl implements ActivityClient, ActivityClientInternal {
private final Scope metricsScope;

ActivityClientImpl(WorkflowServiceStubs stubs, ActivityClientOptions options) {
DataConverter internalDataConverter =
ClientDataConverterFactory.forClient(options.getDataConverter());
this.stubs = stubs;
this.options = options;
this.metricsScope =
stubs.getOptions().getMetricsScope().tagged(MetricsTag.defaultTags(options.getNamespace()));
GenericWorkflowClientImpl genericClient = new GenericWorkflowClientImpl(stubs, metricsScope);
this.invoker = initializeClientInvoker(genericClient, options);
this.invoker = initializeClientInvoker(genericClient, options, internalDataConverter);
this.manualActivityCompletionClientFactory =
ManualActivityCompletionClientFactory.newFactory(
stubs, options.getNamespace(), options.getIdentity(), options.getDataConverter());
stubs, options.getNamespace(), options.getIdentity(), internalDataConverter);
}

private static ActivityClientCallsInterceptor initializeClientInvoker(
GenericWorkflowClientImpl genericClient, ActivityClientOptions options) {
ActivityClientCallsInterceptor invoker = new RootActivityClientInvoker(genericClient, options);
GenericWorkflowClientImpl genericClient,
ActivityClientOptions options,
DataConverter dataConverter) {
ActivityClientCallsInterceptor invoker =
new RootActivityClientInvoker(genericClient, options, dataConverter);
for (ActivityClientInterceptor interceptor : options.getInterceptors()) {
invoker = interceptor.activityClientCallsInterceptor(invoker);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ public <V> void reportCancellationStandalone(

@Override
public <V> void heartbeat(byte[] taskToken, V details) throws ActivityCompletionException {
factory.getClient(taskToken, metricsScope).recordHeartbeat(details);
factory.getClient(taskToken, metricsScope, serializationContext).recordHeartbeat(details);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@
import io.temporal.common.interceptors.NexusClientCallsInterceptor.ListNexusOperationExecutionsOutput;
import io.temporal.common.interceptors.NexusClientInterceptor;
import io.temporal.internal.WorkflowThreadMarker;
import io.temporal.internal.client.ClientDataConverterFactory;
import io.temporal.internal.client.NamespaceInjectWorkflowServiceStubs;
import io.temporal.internal.client.NexusClientResolvedOptions;
import io.temporal.internal.client.NexusOperationHandleImpl;
import io.temporal.internal.client.RootNexusClientInvoker;
import io.temporal.internal.client.external.GenericWorkflowClient;
import io.temporal.internal.client.external.GenericWorkflowClientImpl;
import io.temporal.internal.payload.storage.ExternalStorageRunner;
import io.temporal.serviceclient.MetricsTag;
import io.temporal.serviceclient.WorkflowServiceStubs;
import java.util.List;
Expand All @@ -40,10 +42,17 @@ public class NexusClientImpl implements NexusClient {
public static NexusClient newInstance(WorkflowServiceStubs service, NexusClientOptions options) {
enforceNonWorkflowThread();
return WorkflowThreadMarker.protectFromWorkflowThread(
new NexusClientImpl(service, options.toResolvedOptions()), NexusClient.class);
new NexusClientImpl(service, options), NexusClient.class);
}

NexusClientImpl(WorkflowServiceStubs workflowServiceStubs, NexusClientResolvedOptions options) {
NexusClientImpl(WorkflowServiceStubs workflowServiceStubs, NexusClientOptions clientOptions) {
NexusClientResolvedOptions options =
clientOptions.toResolvedOptions(
clientOptions.getExternalStorage() == null
? ClientDataConverterFactory.forClient(clientOptions.getDataConverter())
: ClientDataConverterFactory.forClient(
clientOptions.getDataConverter(),
ExternalStorageRunner.create(clientOptions.getExternalStorage())));
workflowServiceStubs =
new NamespaceInjectWorkflowServiceStubs(workflowServiceStubs, options.getNamespace());
this.workflowServiceStubs = workflowServiceStubs;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
import io.temporal.common.converter.GlobalDataConverter;
import io.temporal.common.interceptors.NexusClientInterceptor;
import io.temporal.internal.client.NexusClientResolvedOptions;
import io.temporal.internal.payload.storage.ExternalStorageDataConverter;
import io.temporal.internal.payload.storage.ExternalStorageRunner;
import io.temporal.payload.storage.ExternalStorage;
import java.lang.management.ManagementFactory;
import java.util.Collections;
Expand Down Expand Up @@ -92,16 +90,11 @@ public String getIdentity() {
* Converts this {@link NexusClientOptions} instance into a {@link NexusClientResolvedOptions}
* instance, which contains the fully resolved runtime settings used by the internal Nexus client.
*
* @param internalDataConverter converter assembled for internal client use
* @return a {@link NexusClientResolvedOptions} instance with the resolved options
*/
NexusClientResolvedOptions toResolvedOptions() {
DataConverter resolvedDataConverter = dataConverter;
if (externalStorage != null) {
resolvedDataConverter =
new ExternalStorageDataConverter(
resolvedDataConverter, ExternalStorageRunner.create(externalStorage));
}
return new NexusClientResolvedOptions(namespace, interceptors, resolvedDataConverter, identity);
NexusClientResolvedOptions toResolvedOptions(DataConverter internalDataConverter) {
return new NexusClientResolvedOptions(namespace, interceptors, internalDataConverter, identity);
}

/** Returns a fresh builder. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@
import io.temporal.api.workflowservice.v1.*;
import io.temporal.client.WorkflowInvocationHandler.InvocationType;
import io.temporal.common.WorkflowExecutionHistory;
import io.temporal.common.converter.DataConverter;
import io.temporal.common.interceptors.WorkflowClientCallsInterceptor;
import io.temporal.common.interceptors.WorkflowClientInterceptor;
import io.temporal.internal.WorkflowThreadMarker;
import io.temporal.internal.client.*;
import io.temporal.internal.client.ClientDataConverterFactory;
import io.temporal.internal.client.NexusStartWorkflowResponse;
import io.temporal.internal.client.external.GenericWorkflowClient;
import io.temporal.internal.client.external.GenericWorkflowClientImpl;
Expand Down Expand Up @@ -52,6 +54,7 @@ final class WorkflowClientInternalImpl implements WorkflowClient, WorkflowClient

private final GenericWorkflowClient genericClient;
private final WorkflowClientOptions options;
private final DataConverter internalDataConverter;
private final ManualActivityCompletionClientFactory manualActivityCompletionClientFactory;
private final WorkflowClientCallsInterceptor workflowClientCallsInvoker;
private final WorkflowServiceStubs workflowServiceStubs;
Expand Down Expand Up @@ -103,9 +106,11 @@ public static WorkflowClient newInstance(
// Set merged plugins after configuration, then validate
builder.setPlugins(mergedPlugins);
options = builder.validateAndBuildWithDefaults();
this.internalDataConverter = ClientDataConverterFactory.forClient(options.getDataConverter());
this.options =
WorkflowClientOptions.newBuilder(options).setDataConverter(internalDataConverter).build();
workflowServiceStubs =
new NamespaceInjectWorkflowServiceStubs(workflowServiceStubs, options.getNamespace());
this.options = options;
this.workflowServiceStubs = workflowServiceStubs;
this.metricsScope =
workflowServiceStubs
Expand All @@ -124,7 +129,7 @@ public static WorkflowClient newInstance(
workflowServiceStubs,
options.getNamespace(),
options.getIdentity(),
options.getDataConverter(),
getInternalDataConverter(),
externalStorageRunner);

java.time.Duration heartbeatInterval = options.getWorkerHeartbeatInterval();
Expand Down Expand Up @@ -160,13 +165,18 @@ public WorkflowClientOptions getOptions() {
return options;
}

@Override
public DataConverter getInternalDataConverter() {
return internalDataConverter;
}

@Override
@SuppressWarnings("unchecked")
public <T> T newWorkflowStub(Class<T> workflowInterface, WorkflowOptions options) {
checkAnnotation(workflowInterface, WorkflowMethod.class);
WorkflowInvocationHandler invocationHandler =
new WorkflowInvocationHandler(
workflowInterface, this.getOptions(), workflowClientCallsInvoker, options);
workflowInterface, this.options, workflowClientCallsInvoker, options);
return (T)
Proxy.newProxyInstance(
workflowInterface.getClassLoader(),
Expand Down Expand Up @@ -243,7 +253,7 @@ public <T> T newWorkflowStub(
WorkflowInvocationHandler invocationHandler =
new WorkflowInvocationHandler(
workflowInterface,
this.getOptions(),
options,
workflowClientCallsInvoker,
execution.build(),
legacyTargeting,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import static io.temporal.internal.WorkflowThreadMarker.enforceNonWorkflowThread;

import com.uber.m3.tally.Scope;
import io.temporal.common.converter.DataConverter;
import io.temporal.common.interceptors.ScheduleClientCallsInterceptor;
import io.temporal.common.interceptors.ScheduleClientInterceptor;
import io.temporal.internal.WorkflowThreadMarker;
import io.temporal.internal.client.ClientDataConverterFactory;
import io.temporal.internal.client.NamespaceInjectWorkflowServiceStubs;
import io.temporal.internal.client.RootScheduleClientInvoker;
import io.temporal.internal.client.external.GenericWorkflowClient;
Expand Down Expand Up @@ -71,6 +73,8 @@ public static ScheduleClient newInstance(
// Set merged plugins after configuration, then build
builder.setPlugins(mergedPlugins);
options = builder.build();
DataConverter internalDataConverter =
ClientDataConverterFactory.forClient(options.getDataConverter());

workflowServiceStubs =
new NamespaceInjectWorkflowServiceStubs(workflowServiceStubs, options.getNamespace());
Expand All @@ -83,7 +87,7 @@ public static ScheduleClient newInstance(
.tagged(MetricsTag.defaultTags(options.getNamespace()));
this.genericClient = new GenericWorkflowClientImpl(workflowServiceStubs, metricsScope);
this.interceptors = options.getInterceptors();
this.scheduleClientCallsInvoker = initializeClientInvoker();
this.scheduleClientCallsInvoker = initializeClientInvoker(internalDataConverter);
}

private static ScheduleClientPlugin[] extractScheduleClientPlugins(
Expand All @@ -100,9 +104,9 @@ private static ScheduleClientPlugin[] extractScheduleClientPlugins(
return schedulePlugins.toArray(new ScheduleClientPlugin[0]);
}

private ScheduleClientCallsInterceptor initializeClientInvoker() {
private ScheduleClientCallsInterceptor initializeClientInvoker(DataConverter dataConverter) {
ScheduleClientCallsInterceptor scheduleClientInvoker =
new RootScheduleClientInvoker(genericClient, options);
new RootScheduleClientInvoker(genericClient, options, dataConverter);
for (ScheduleClientInterceptor clientInterceptor : interceptors) {
scheduleClientInvoker =
clientInterceptor.scheduleClientCallsInterceptor(scheduleClientInvoker);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
package io.temporal.common.converter;

import com.google.common.base.Preconditions;
import io.temporal.api.common.v1.Payload;
import io.temporal.internal.common.converter.TemporalTransferTypeDataConverter;
import io.temporal.internal.common.converter.TemporalTransferTypeDataConverter.TransferType;
import io.temporal.payload.context.SerializationContext;
import java.lang.reflect.Type;
import java.util.*;
import javax.annotation.Nonnull;

/**
* A {@link DataConverter} that delegates payload conversion to type specific {@link
* PayloadConverter} instances, and delegates failure conversions to a {@link FailureConverter}.
* Top-level values annotated with {@link TransferTypeConvertible} are converted to their transfer
* types before payload conversion, including when this converter is used directly.
*/
public class DefaultDataConverter extends PayloadAndFailureDataConverter {

// Order is important as the first converter that can convert the payload is used. Needs to match
// the other SDKs. Go SDK:
// https://github.com/temporalio/sdk-go/blob/5e5645f0c550dcf717c095ae32c76a7087d2e985/converter/default_data_converter.go#L28
Expand Down Expand Up @@ -62,6 +68,36 @@ public DefaultDataConverter(PayloadConverter... converters) {
super(Arrays.asList(converters));
}

private DefaultDataConverter(
List<PayloadConverter> converters,
Map<String, PayloadConverter> convertersMap,
FailureConverter failureConverter,
SerializationContext context) {
super(converters, convertersMap, failureConverter, context);
}

@Override
public <T> Optional<Payload> toPayload(T value) throws DataConverterException {
return super.toPayload(TemporalTransferTypeDataConverter.toTransferValue(value));
}

@Override
public <T> T fromPayload(Payload payload, Class<T> valueClass, Type valueType)
throws DataConverterException {
TransferType transfer =
TemporalTransferTypeDataConverter.transferTypeFor(valueClass, valueType);
if (transfer == null) {
return super.fromPayload(payload, valueClass, valueType);
}
Object decoded = super.fromPayload(payload, transfer.getRawType(), transfer.getType());
return transfer.fromValue(decoded, valueClass);
}

@Override
public DataConverter withContext(@Nonnull SerializationContext context) {
return new DefaultDataConverter(converters, convertersMap, failureConverter, context);
}

/**
* Modifies this {@code DefaultDataConverter} by overriding some of its {@link PayloadConverter}s.
* Every payload converter from {@code overrideConverters} either replaces existing payload
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import io.temporal.api.common.v1.Payloads;
import io.temporal.api.failure.v1.Failure;
import io.temporal.failure.DefaultFailureConverter;
import io.temporal.internal.common.converter.TemporalTransferTypeDataConverter;
import io.temporal.payload.context.SerializationContext;
import java.lang.reflect.Type;
import java.util.*;
Expand Down Expand Up @@ -130,7 +131,7 @@ public RuntimeException failureToException(@Nonnull Failure failure) {
return (serializationContext != null
? failureConverter.withContext(serializationContext)
: failureConverter)
.failureToException(failure, this);
.failureToException(failure, TemporalTransferTypeDataConverter.wrap(this));
}

@Override
Expand All @@ -140,7 +141,7 @@ public Failure exceptionToFailure(@Nonnull Throwable throwable) {
return (serializationContext != null
? failureConverter.withContext(serializationContext)
: failureConverter)
.exceptionToFailure(throwable, this);
.exceptionToFailure(throwable, TemporalTransferTypeDataConverter.wrap(this));
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package io.temporal.common.converter;

import io.temporal.common.Experimental;
import java.lang.reflect.Type;
import javax.annotation.Nullable;

/**
* Converts a model to and from a representation handled by the configured {@link DataConverter}.
*
* <p>Conversion is applied only to top-level values. The {@code valueType} arguments contain the
* complete requested model type, including generic arguments. Implementations must be stateless and
* thread-safe because converter instances are cached.
*
* @param <T> annotated model type converted to and from its transfer representation
*/
@Experimental
public interface TransferTypeConverter<T> {
/**
* Returns the type used to serialize a model value.
*
* @param valueType complete declared model type, including generic arguments
* @return non-null transfer type
*/
Type getTransferType(Type valueType);

/** Converts a model value to its transfer representation, which may be null. */
@Nullable
Object toTransferType(T value);

/**
* Reconstructs a model value from its transfer representation.
*
* @param value transfer representation decoded by the configured data converter, which may be
* null
* @param valueType complete requested model type, including generic arguments
*/
T fromTransferType(@Nullable Object value, Type valueType);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package io.temporal.common.converter;

import io.temporal.common.Experimental;
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;

/**
* Associates a model class with the converter used to produce its transfer representation.
*
* <p>The annotation is read only from the exact declared class and is not inherited. Conversion is
* applied to top-level values within a transfer-aware data converter. The configured {@link
* DataConverter} remains responsible for serialization, payload codecs, and wire encoding.
*
* <p>The converter and its enclosing classes must be public. The converter must be concrete and
* have a public no-argument constructor. Converter instances are cached, so implementations must be
* stateless and thread-safe.
*/
@Experimental
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface TransferTypeConvertible {
/** The converter associated with the annotated model class. */
@SuppressWarnings("rawtypes")
Class<? extends TransferTypeConverter> value();
}
Loading
Loading