Skip to content

Add transfer type payload conversion hooks#795

Open
tconley1428 wants to merge 3 commits into
mainfrom
data-model-payload-hooks
Open

Add transfer type payload conversion hooks#795
tconley1428 wants to merge 3 commits into
mainfrom
data-model-payload-hooks

Conversation

@tconley1428

@tconley1428 tconley1428 commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Adds experimental SDK payload-converter support for values and target types marked with TemporalTransferTypeAttribute.

The attribute points at an ITemporalTransferTypeConverter implementation. The converter supplies the TransferType handed to the configured payload converter, converts user values into that representation for encoding, and reconstructs user values from it for decoding. General transfer type hooks intentionally do not receive a payload converter.

This keeps normal SDK payload-converter behavior in one place, including codec handling and serialization-context propagation.

This change:

  • wraps payload converters when they are stored on DataConverter, including with { PayloadConverter = ... }
  • adds experimental public TemporalTransferTypeAttribute and ITemporalTransferTypeConverter APIs
  • keeps the actual payload-converter adapter internal
  • caches validated transfer type converter instances per marked type
  • applies hooks before delegating to the configured payload converter
  • adds converter tests proving the transformed transfer type is encoded with the active workflow serialization context

@tconley1428
tconley1428 requested a review from a team as a code owner July 21, 2026 16:15
@tconley1428
tconley1428 marked this pull request as draft July 21, 2026 16:15
@tconley1428
tconley1428 force-pushed the data-model-payload-hooks branch from 2c28d33 to d1228be Compare July 21, 2026 18:07
@tconley1428 tconley1428 changed the title Add data model payload conversion hooks Add transfer type payload conversion hooks Jul 22, 2026
@tconley1428
tconley1428 marked this pull request as ready for review July 23, 2026 18:05
$"{attr.ConverterType}.");
}

if (Activator.CreateInstance(attr.ConverterType) is not ITemporalTransferTypeConverter converter)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

While you check a few cases above, there are other ones, such as:

  • Type could be abstract e.g. attr.ConverterType.IsAbstract
  • Type constructor could have parameters e.g. !attr.ConverterType.IsValueType && attr.ConverterType.GetConstructor(Type.EmptyTypes) == null; all value types (e.g. struct have a default parameterless constructor, so you can ignore those.
  • Type constructor must be public; GetConstructor will return null if not public instance constructor.

$"{attr.ConverterType}.");
}

if (Activator.CreateInstance(attr.ConverterType) is not ITemporalTransferTypeConverter converter)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Activator.CreateInstance can throw exceptions, especially for all of the cases you are checking. But it can also throw exceptions due to execution problems, such as the target type constructor raises an exception, the static constructor for the type raises an exception, a dependent library cannot be loaded, etc. All different types of exceptions you cannot anticipate the types of (or you can, there's just a lot of them). Not sure if you want to let those bubble out as-is, which would probably be fine to do. Just bringing awareness.

/// This API is experimental and may change in a future release.
/// </remarks>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = true)]
public sealed class TemporalTransferTypeAttribute : Attribute

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Rename to TemporalTransferTypeConverterAttribute to match .NET conventions.

/// <see cref="ITemporalTransferTypeConverter"/>.
/// This API is experimental and may change in a future release.
/// </remarks>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = true)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think we should disallow inheritance (set Inherited = false). The reason being that if someone gets clever to create a base type with this attribute and a derived type without the attribute, then the converter on the base type better know how to dynamically inspect the transfer type to turn it back into the model type; seems like something easy to stumble into. With Inherited = false, each derivation needs to apply the attribute and declare which converter understands it; if the converter is polymorphic aware, it can be explicitly applied to each derivation.

I'd add a bunch of tests around this.


private static ITemporalTransferTypeConverter? CreateConverter(Type type)
{
var attr = type.GetCustomAttribute<TemporalTransferTypeAttribute>(inherit: true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Flip to false to match https://github.com/temporalio/sdk-dotnet/pull/795/changes#r3641267623. I think we want the runtime type to actually declare it's converter rather than getting something via inheritance.

/// <summary>
/// Gets the payload converter.
/// </summary>
public IPayloadConverter PayloadConverter

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would not do this since it breaks the record semantics. I think if you construct two separate DataConverter instances with the same payload converter, failure converter, and payload codec, before this change they would appear to be equal but after this change they would appear to be unequal.

This becomes acute for plugins, which could try to wrap a payload converter from the DataConverter given to it with it's own. That'll cause an old TemporalTransferTypePayloadConverter to be sandwiched between a user-defined and a plugin-defined payload converter, and all wrapped by another TemporalTransferTypePayloadConverter payload converter. Maybe not detrimental, but definitely unexpected.

I would apply this at TemporalClient.ConnectAsync just after plugins do their adjustments and before we actually consume the options within the client infrastructure. The other place that DataConverter is provided is via HeartbeatAsyncActivityInput, but I'm not sure we really need to wrap that there. If just for system Nexus operations, probably not, but if as a general transfer type mechanism, then probably yes.

public IPayloadConverter PayloadConverter
{
get => payloadConverter;
init => payloadConverter = TemporalTransferTypePayloadConverter.Wrap(value);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't think the init is necessary; everyone is already constructing the DataConverter by passing the payload converter via the constructor.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants