feat(sdk): 💥 add typed child workflow handle#1182
Conversation
| started_child.signal(sig).await.unwrap(); | ||
| started_child.result().await.status.unwrap(); | ||
| started_child | ||
| .signal(ChildSignalReceiver::handle_signal, "hi!".into()) |
There was a problem hiding this comment.
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.
| assert!( | ||
| wft_failures.is_empty(), | ||
| "Expected no WorkflowTaskFailed events on child workflow, but found: {wft_failures:?}", | ||
| ); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
Wasn't sure what the point of this intermediate struct was.
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
The rest of this method is copied from ChildWorkflow::start
8b13b75 to
cccea0b
Compare
|
@claude review |
There was a problem hiding this comment.
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.
|
|
||
| /// Error returned when signaling a child workflow fails. | ||
| #[derive(Debug, thiserror::Error)] | ||
| pub enum ChildWorkflowSignalError { |
There was a problem hiding this comment.
In a similar manner, the raw Failure variant will be removed in a future PR.
| /// 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() { |
There was a problem hiding this comment.
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)" ...
|
|
||
| /// A stub representing an unstarted child workflow. | ||
| #[derive(Clone, derive_more::Debug)] | ||
| pub struct ChildWorkflow { |
There was a problem hiding this comment.
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)
|
|
||
| /// Wrapper future for signaling a child workflow. Allows returning serialization errors | ||
| /// eagerly instead of panicking. | ||
| enum SignalChildFut<F> { |
There was a problem hiding this comment.
I'm sure I'm missing this but I don't see where this gets turned into a command?
There was a problem hiding this comment.
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)
| impl WorkflowDefinition for UntypedWorkflow { | ||
| type Input = RawValue; | ||
| type Output = RawValue; | ||
| fn name(&self) -> &str { | ||
| &self.name | ||
| } | ||
| } |
There was a problem hiding this comment.
🔴 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
- Worker is configured with
DataConverter::new(PayloadConverter::serde_json(), ...). - Parent calls
ctx.child_workflow(UntypedWorkflow::new("my-wf"), RawValue::new(vec![]), opts).await. BaseWorkflowContext::child_workflowcallspayload_converter.to_payloads::<RawValue>(&ctx, &input).PayloadConverter::Serdearm:RawValueis not(), soval.as_serde()?is called.RawValue::as_serde()returnsErr(WrongEncoding)(trait default).child_workflow()returnsChildWorkflowStartFut::Errored(Serialization(WrongEncoding))..awaiton the future immediately yieldsErr(ChildWorkflowExecutionError::Serialization(WrongEncoding))— child never starts. For the deserialization path: even if the child completes successfully, step 3 instead callsfrom_payloads::<RawValue>which callsRawValue::from_serde(...)→Err(WrongEncoding)→Err(ChildWorkflowExecutionError::Serialization(WrongEncoding)).
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>
65580e8 to
b9b2fa1
Compare
What was changed
UntypedWorkflowfromtemporalio-clienttotemporalio-common(will still be reexported from client).WorkflowContext::child_workflowresemble starting a workflow from a client. Previously it just tookChildWorkflowOptionswhich 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.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
Failurevariant that will be removed in the near future.Checklist
Closes
How was this tested:
Updated integration tests. Duplicated some existing ones to make sure untyped path still worked.
Any docs updates needed?
Done