Skip to content

feat(sdk): 💥 add typed child workflow handle#1182

Merged
chris-olszewski merged 13 commits into
masterfrom
olszewski/feat_typed_child_workflow
Mar 26, 2026
Merged

feat(sdk): 💥 add typed child workflow handle#1182
chris-olszewski merged 13 commits into
masterfrom
olszewski/feat_typed_child_workflow

Conversation

@chris-olszewski

@chris-olszewski chris-olszewski commented Mar 25, 2026

Copy link
Copy Markdown
Member

What was changed

  • Moved UntypedWorkflow from temporalio-client to temporalio-common (will still be reexported from client).
  • Change WorkflowContext::child_workflow resemble starting a workflow from a client. Previously it just took ChildWorkflowOptions which would have workflow type and input stuffed into it as a raw string and payloads respectively. It now takes a workflow run marker struct and whatever the associated input type is.
  • Update tests (where applicable) to use the new typed handler. Did not update replay tests to have real child workflows so they remained untyped, there also some tests that explicitly use a non-existent workflow type.

Why?

Who doesn't love types?

I'm sequencing this before a larger error type overhaul where we extract information from failure protos to more precise error variants. Due to that the new error types for child workflow interactions still have that raw Failure variant that will be removed in the near future.

Checklist

  1. Closes

  2. How was this tested:
    Updated integration tests. Duplicated some existing ones to make sure untyped path still worked.

  3. Any docs updates needed?
    Done

started_child.signal(sig).await.unwrap();
started_child.result().await.status.unwrap();
started_child
.signal(ChildSignalReceiver::handle_signal, "hi!".into())

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I checked and other SDKs don't appear to allow setting headers on signals sent to children via the child handle. I don't believe this was the important aspect of this specific test.

Comment on lines +312 to +315
assert!(
wft_failures.is_empty(),
"Expected no WorkflowTaskFailed events on child workflow, but found: {wft_failures:?}",
);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think this is an assertion that I need to add elsewhere in the codebase for tests that do assertions in workflows, but do not end up checking the result. This test was passing, but had a warning from the wft failing due to the failed assertion, but we were not checking the workflow outcome.

Post my soon coming failure converter/error cleanup this will become far better where we can just grab workflow result directly.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Makes sense. We might want a helper for this, because it's entirely possible for children to have WFT failures that are both expected and unexpected but they return a successful result in both cases.


/// Error returned when a child workflow execution fails.
#[derive(Debug, thiserror::Error)]
pub enum ChildWorkflowExecutionError {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Note this is not the final failure type. Post failure converter PR this error type will be fleshed out, but now it mirrors the current ActivityExecutionError.


/// A stub representing an unstarted child workflow.
#[derive(Clone, derive_more::Debug)]
pub struct ChildWorkflow {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Wasn't sure what the point of this intermediate struct was.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think the idea was just copying a child workflow w/ the same options. That doesn't work quite the same now that type/input is moved to the callsite. (Which is fine)

};
let workflow_type = workflow.name().to_string();

let child_seq = self.inner.seq_nums.borrow_mut().next_child_workflow_seq();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The rest of this method is copied from ChildWorkflow::start

@chris-olszewski
chris-olszewski force-pushed the olszewski/feat_typed_child_workflow branch 2 times, most recently from 8b13b75 to cccea0b Compare March 26, 2026 01:22
@chris-olszewski
chris-olszewski marked this pull request as ready for review March 26, 2026 01:29
@chris-olszewski
chris-olszewski requested a review from a team as a code owner March 26, 2026 01:29
@chris-olszewski

Copy link
Copy Markdown
Member Author

@claude review

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is a well-structured breaking-change PR, but the scope warrants a human look before it merges.

Extended reasoning...

Overview

The PR moves UntypedWorkflow from temporalio-client to temporalio-common, replaces the child_workflow(ChildWorkflowOptions) API with a typed overload, introduces three new generic future types, makes StartedChildWorkflow generic over WD, removes the ChildWorkflow intermediate stub, and adds new error enums. Integration tests across five files are updated.

Security risks

No security-sensitive code paths are touched.

Level of scrutiny

High. This is a breaking change that directly modifies the public WorkflowContext API. The new generic future machinery is non-trivial and incorrect poll/cancel wiring would silently corrupt workflow history.

Other factors

The two bugs flagged are both pre-existing. The run_timeout/execution_timeout mix-up in options.rs:273 is in code this PR directly refactors and would be easy to fix here. The cancel() sequence-number bug is in a doc(hidden) method already marked TODO-remove. The author left several self-review notes indicating known temporary states, signaling this PR is intentionally incremental; a human reviewer should assess whether the intermediate API shape is acceptable for a breaking release.

Comment thread crates/sdk/src/workflow_context/options.rs
Comment thread crates/sdk/src/workflow_context.rs

/// Error returned when signaling a child workflow fails.
#[derive(Debug, thiserror::Error)]
pub enum ChildWorkflowSignalError {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

In a similar manner, the raw Failure variant will be removed in a future PR.

Comment thread crates/sdk/src/workflow_context.rs
/// sequence counters. The child cancel must use child_seq (not
/// cancel_external_wf_seq) or we get NDE.
#[tokio::test]
async fn cancel_child_after_cancel_external_uses_correct_seq() {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Claude did good. Before fix was applied this would trigger NDE:

Failing workflow task run_id=019d2a7e-5367-75ff-9c0e-cacc8f7811fc failure=Failure { failure: Some(Failure { message: "[TMPRL1100] Nondeterminism error: Missing associated machine for ChildWorkflowStart(2)" ...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Niiiice

Comment thread crates/sdk/src/workflow_context.rs Outdated
Comment thread crates/sdk/src/workflow_context.rs
Comment thread crates/sdk-core/tests/integ_tests/workflow_tests/child_workflows.rs Outdated
Comment thread crates/sdk-core/tests/integ_tests/workflow_tests/child_workflows.rs
Comment thread crates/sdk-core/tests/integ_tests/workflow_tests/child_workflows.rs Outdated
Comment thread crates/sdk-core/tests/integ_tests/workflow_tests/child_workflows.rs Outdated

/// A stub representing an unstarted child workflow.
#[derive(Clone, derive_more::Debug)]
pub struct ChildWorkflow {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think the idea was just copying a child workflow w/ the same options. That doesn't work quite the same now that type/input is moved to the callsite. (Which is fine)

Comment thread crates/sdk/src/workflow_context.rs Outdated

/// Wrapper future for signaling a child workflow. Allows returning serialization errors
/// eagerly instead of panicking.
enum SignalChildFut<F> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm sure I'm missing this but I don't see where this gets turned into a command?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Down when we construct the running variant in the signal call: https://github.com/temporalio/sdk-core/pull/1182/changes#diff-87417cb4d291b3ccbfb3f288aaecbf8aa018ce839d2d4a6f9a2e034c8b8d0f6bR1956 (hopefully that link works in some meaningful way)

Comment thread crates/sdk-core/tests/integ_tests/workflow_tests/child_workflows.rs Outdated
Comment thread crates/sdk-core/tests/integ_tests/workflow_tests/child_workflows.rs
Comment on lines +37 to +43
impl WorkflowDefinition for UntypedWorkflow {
type Input = RawValue;
type Output = RawValue;
fn name(&self) -> &str {
&self.name
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Both UntypedWorkflow::Input = RawValue serialization and UntypedWorkflow::Output = RawValue deserialization always return WrongEncoding when the worker is configured with PayloadConverter::serde_json() directly (not the default composite converter). Fix: add as_serde and from_serde overrides to RawValue's TemporalSerializable/TemporalDeserializable impls in data_converters.rs.

Extended reasoning...

What the bug is and how it manifests

RawValue manually implements TemporalSerializable (overriding to_payload/to_payloads) and TemporalDeserializable (overriding from_payload/from_payloads), but does NOT override as_serde() or from_serde(). Since RawValue derives only Clone, Debug, Default — not serde::Serialize or serde::Deserialize — the blanket impls for those traits do not apply. Both trait defaults unconditionally return Err(PayloadConversionError::WrongEncoding). As a result, any worker using PayloadConverter::serde_json() directly will fail every UntypedWorkflow child workflow interaction.

The specific code paths that trigger it

For input serialization (as_serde side): BaseWorkflowContext::child_workflow calls payload_converter.to_payloads::<RawValue>(&ctx, &input). Under PayloadConverter::Serde, GenericPayloadConverter::to_payloads::<RawValue> takes the non-() branch (data_converters.rs ~465–472) and calls val.as_serde()?. Because RawValue does not override as_serde(), this falls back to the trait default and immediately returns Err(WrongEncoding), causing ChildWorkflowStartFut::eager(...) to be returned before any command is sent to Core — the child is never started.

For output deserialization (from_serde side): ChildWorkflowFut::poll (new code introduced by this PR) calls payload_converter.from_payloads::<Output>(&ctx, payloads) where Output = RawValue. Under PayloadConverter::Serde, GenericPayloadConverter::from_payloads::<RawValue> requires payloads.len() == 1 then calls T::from_serde(pc.as_ref(), context, payload) (data_converters.rs:503–507). RawValue::from_serde falls back to the default returning Err(WrongEncoding), so started.result().await always yields Err(ChildWorkflowExecutionError::Serialization(WrongEncoding)) even for successfully-completed child workflows.

Why existing code does not prevent it

The default PayloadConverter is Composite([UseWrappers, serde_json]). The UseWrappers arm calls T::to_payloads(val, context) and T::from_payloads(context, payloads) directly — bypassing as_serde/from_serde entirely — and these are correctly overridden on RawValue. So the default converter works fine, and all existing integration tests (including the new untyped_child_workflow_happy_path) pass because they all use the default converter.

Why this is introduced by the PR (addressing the pre-existing refutation)

One verifier argued that as_serde was pre-existing since UntypedWorkflow::Input = RawValue existed before in crates/client/src/workflow_handle.rs. However, the critical distinction is that the old ChildWorkflowOptions accepted Vec<Payload> for its input field, passing them directly to the proto command without going through any type-based serialization. This PR changes the API to route child workflow input through payload_converter.to_payloads::<WD::Input>(), which is the new code path that first exposes the as_serde gap for RawValue in workflow execution. The from_serde gap for the result path (ChildWorkflowFut) is unambiguously new code introduced entirely by this PR.

Impact and fix

Any user who constructs DataConverter::new(PayloadConverter::serde_json(), ...) — a documented public API — and uses UntypedWorkflow as a child workflow will get Serialization(WrongEncoding) on every start or result await. Fix: in data_converters.rs, add fn as_serde(&self) -> Result<...> { ... } to RawValue's TemporalSerializable impl (returning an appropriate serde representation of its payload bytes, or redirecting to to_payloads) and add fn from_serde(...) -> Result<Self, ...> { Ok(RawValue { payloads: vec![payload] }) } to its TemporalDeserializable impl.

Step-by-step proof

  1. Worker is configured with DataConverter::new(PayloadConverter::serde_json(), ...).
  2. Parent calls ctx.child_workflow(UntypedWorkflow::new("my-wf"), RawValue::new(vec![]), opts).await.
  3. BaseWorkflowContext::child_workflow calls payload_converter.to_payloads::<RawValue>(&ctx, &input).
  4. PayloadConverter::Serde arm: RawValue is not (), so val.as_serde()? is called.
  5. RawValue::as_serde() returns Err(WrongEncoding) (trait default).
  6. child_workflow() returns ChildWorkflowStartFut::Errored(Serialization(WrongEncoding)).
  7. .await on the future immediately yields Err(ChildWorkflowExecutionError::Serialization(WrongEncoding)) — child never starts. For the deserialization path: even if the child completes successfully, step 3 instead calls from_payloads::<RawValue> which calls RawValue::from_serde(...)Err(WrongEncoding)Err(ChildWorkflowExecutionError::Serialization(WrongEncoding)).

chris-olszewski and others added 13 commits March 26, 2026 18:49
The common crate is a more appropriate home since both the client and
SDK crates need this type. A re-export in the client crate preserves
backward compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Mirrors ActivityExecutionError with Failed, Cancelled, and
Serialization variants. Will be used by the typed child workflow API
in the next commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the two-step `child_workflow(opts).start()` pattern with a
single `child_workflow(workflow_def, input, opts)` call that carries
type information through to the result.

Key changes:
- `child_workflow()` now takes a WorkflowDefinition, typed input, and
  options — workflow_type and input are removed from ChildWorkflowOptions
- PendingChildWorkflow<WD> and StartedChildWorkflow<WD> are generic
  over the workflow definition
- `result()` returns Result<WD::Output, ChildWorkflowExecutionError>
  instead of raw ChildWorkflowResult
- `signal()` accepts a SignalDefinition with typed input
- Remove the ChildWorkflow stub struct entirely
- Update all tests to the new API

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@chris-olszewski
chris-olszewski force-pushed the olszewski/feat_typed_child_workflow branch from 65580e8 to b9b2fa1 Compare March 26, 2026 22:49
@chris-olszewski
chris-olszewski merged commit 21b65b8 into master Mar 26, 2026
21 checks passed
@chris-olszewski
chris-olszewski deleted the olszewski/feat_typed_child_workflow branch March 26, 2026 23:02
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