Skip to content

Commit 26a20eb

Browse files
committed
feat(workflow): support TypeInfo for workflow transitions
1 parent d8e2f2e commit 26a20eb

5 files changed

Lines changed: 214 additions & 13 deletions

File tree

packages/test/src/test-type-info.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { randomUUID } from 'crypto';
22
import type { ExecutionContext } from 'ava';
3-
import { Client, WithStartWorkflowOperation } from '@temporalio/client';
3+
import { Client, WithStartWorkflowOperation, WorkflowFailedError } from '@temporalio/client';
44
import { workflowInterceptorModules } from '@temporalio/testing';
55
import { bundleWorkflowCode } from '@temporalio/worker';
66
import type { TestWorkflowEnvironment } from './helpers';
@@ -12,9 +12,13 @@ import {
1212
makeConfigurableEnvironmentTestFn,
1313
} from './helpers-integration';
1414
import {
15+
parentWorkflowChildDefinition,
16+
parentWorkflowChildDefinitionInvalidCallSiteTypeInfo,
17+
continueAsNewToWorkflowWithTypeInfo,
1518
finishSignal,
1619
finishUpdate,
1720
Order,
21+
parentWorkflowChildString,
1822
Receipt,
1923
workflowTypeInfo,
2024
workflowWithSignalStart,
@@ -158,6 +162,38 @@ test('detached workflow handle uses call-site output type information', async (t
158162
});
159163
});
160164

165+
test('same-type continue-as-new reuses definition-supplied input and output type information', async (t) => {
166+
const h = configurableHelpers(t, t.context.workflowBundle, t.context.env);
167+
const client = makeClient(t.context.env);
168+
const worker = await h.createWorker({ dataConverter });
169+
170+
await worker.runUntil(async () => {
171+
const result = await client.workflow.execute(workflowWithTypeInfo, {
172+
workflowId: `wf-${randomUUID()}`,
173+
taskQueue: h.taskQueue,
174+
args: [new Order('order-1', 12345n, 1)],
175+
});
176+
177+
assertReceipt(t, result);
178+
});
179+
});
180+
181+
test('continue-as-new to a different workflow uses explicit input type information', async (t) => {
182+
const h = configurableHelpers(t, t.context.workflowBundle, t.context.env);
183+
const client = makeClient(t.context.env);
184+
const worker = await h.createWorker({ dataConverter });
185+
186+
await worker.runUntil(async () => {
187+
const result = await client.workflow.execute(continueAsNewToWorkflowWithTypeInfo, {
188+
workflowId: `wf-${randomUUID()}`,
189+
taskQueue: h.taskQueue,
190+
args: [new Order('order-1', 12345n)],
191+
});
192+
193+
assertReceipt(t, result);
194+
});
195+
});
196+
161197
test('signal-with-start carries definition-supplied workflow type information', async (t) => {
162198
const h = configurableHelpers(t, t.context.workflowBundle, t.context.env);
163199
const client = makeClient(t.context.env);
@@ -195,3 +231,54 @@ test('update-with-start carries definition-supplied workflow type information',
195231
assertReceipt(t, await (await startOperation.workflowHandle()).result());
196232
});
197233
});
234+
235+
test('child workflow uses definition-supplied input and output type information', async (t) => {
236+
const h = configurableHelpers(t, t.context.workflowBundle, t.context.env);
237+
const client = makeClient(t.context.env);
238+
const worker = await h.createWorker({ dataConverter });
239+
240+
await worker.runUntil(async () => {
241+
const result = await client.workflow.execute(parentWorkflowChildDefinition, {
242+
workflowId: `wf-${randomUUID()}`,
243+
taskQueue: h.taskQueue,
244+
args: [new Order('order-1', 12345n)],
245+
});
246+
247+
assertReceipt(t, result);
248+
});
249+
});
250+
251+
test('child workflow uses call-site input and output type information for string workflow type', async (t) => {
252+
const h = configurableHelpers(t, t.context.workflowBundle, t.context.env);
253+
const client = makeClient(t.context.env);
254+
const worker = await h.createWorker({ dataConverter });
255+
256+
await worker.runUntil(async () => {
257+
const result = await client.workflow.execute(parentWorkflowChildString, {
258+
workflowId: `wf-${randomUUID()}`,
259+
taskQueue: h.taskQueue,
260+
args: [new Order('order-1', 12345n)],
261+
});
262+
263+
assertReceipt(t, result);
264+
});
265+
});
266+
267+
test('child workflow definition with call-site type information is invalid', async (t) => {
268+
const h = configurableHelpers(t, t.context.workflowBundle, t.context.env);
269+
const client = makeClient(t.context.env);
270+
const worker = await h.createWorker({ dataConverter });
271+
272+
await worker.runUntil(async () => {
273+
const err = await t.throwsAsync(
274+
client.workflow.execute(parentWorkflowChildDefinitionInvalidCallSiteTypeInfo, {
275+
workflowId: `wf-${randomUUID()}`,
276+
taskQueue: h.taskQueue,
277+
args: [new Order('order-1', 12345n)],
278+
}),
279+
{ instanceOf: WorkflowFailedError }
280+
);
281+
282+
t.regex(err?.cause?.message ?? '', /Workflow type information cannot be supplied at the call site/);
283+
});
284+
});

packages/test/src/workflows/type-info.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
import type { PayloadTypeInfo, TypeInfo } from '@temporalio/common';
2-
import { condition, defineSignal, defineUpdate, defineWorkflowOptions, setHandler } from '@temporalio/workflow';
2+
import {
3+
condition,
4+
continueAsNew,
5+
defineSignal,
6+
defineUpdate,
7+
defineWorkflowOptions,
8+
executeChild,
9+
makeContinueAsNewFunc,
10+
setHandler,
11+
} from '@temporalio/workflow';
312

413
export class Order {
514
constructor(
@@ -75,14 +84,72 @@ function assertOrder(order: Order): void {
7584
}
7685
}
7786

87+
function assertReceipt(receipt: Receipt): void {
88+
if (!(receipt instanceof Receipt)) {
89+
throw new Error('Expected Receipt result');
90+
}
91+
if (typeof receipt.totalCents !== 'bigint') {
92+
throw new Error('Expected Receipt.totalCents to be a bigint');
93+
}
94+
}
95+
7896
defineWorkflowOptions(workflowWithTypeInfo, {
7997
staticOptions: { typeInfo: workflowTypeInfo },
8098
});
8199
export async function workflowWithTypeInfo(order: Order): Promise<Receipt> {
82100
assertOrder(order);
101+
if (order.remainingRuns > 0) {
102+
await continueAsNew(new Order(order.id, order.totalCents, order.remainingRuns - 1));
103+
}
83104
return new Receipt(order.id, order.totalCents);
84105
}
85106

107+
export async function parentWorkflowChildDefinition(order: Order): Promise<Receipt> {
108+
assertOrder(order);
109+
const receipt = await executeChild(workflowWithTypeInfo, { args: [order] });
110+
assertReceipt(receipt);
111+
return receipt;
112+
}
113+
defineWorkflowOptions(parentWorkflowChildDefinition, {
114+
staticOptions: { typeInfo: workflowTypeInfo },
115+
});
116+
117+
export async function parentWorkflowChildString(order: Order): Promise<Receipt> {
118+
assertOrder(order);
119+
const receipt = await executeChild('workflowWithTypeInfo', {
120+
args: [order],
121+
typeInfo: workflowTypeInfo,
122+
});
123+
assertReceipt(receipt);
124+
return receipt;
125+
}
126+
defineWorkflowOptions(parentWorkflowChildString, {
127+
staticOptions: { typeInfo: workflowTypeInfo },
128+
});
129+
130+
export async function parentWorkflowChildDefinitionInvalidCallSiteTypeInfo(order: Order): Promise<void> {
131+
await executeChild(workflowWithTypeInfo, {
132+
args: [order],
133+
typeInfo: workflowTypeInfo,
134+
});
135+
}
136+
defineWorkflowOptions(parentWorkflowChildDefinitionInvalidCallSiteTypeInfo, {
137+
workflowDefinitionOptions: { failureExceptionTypes: [TypeError] },
138+
staticOptions: { typeInfo: workflowTypeInfo },
139+
});
140+
141+
export async function continueAsNewToWorkflowWithTypeInfo(order: Order): Promise<Receipt> {
142+
assertOrder(order);
143+
const continueAsTypedWorkflow = makeContinueAsNewFunc<typeof workflowWithTypeInfo>({
144+
workflowType: 'workflowWithTypeInfo',
145+
typeInfo: { inputTypes: workflowTypeInfo.inputTypes },
146+
});
147+
return await continueAsTypedWorkflow(order);
148+
}
149+
defineWorkflowOptions(continueAsNewToWorkflowWithTypeInfo, {
150+
staticOptions: { typeInfo: workflowTypeInfo },
151+
});
152+
86153
export const finishSignal = defineSignal('finish');
87154

88155
export async function workflowWithSignalStart(order: Order): Promise<Receipt> {

packages/workflow/src/interfaces.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
VersioningBehavior,
1717
InitialVersioningBehavior,
1818
SuggestContinueAsNewReason,
19+
PayloadTypeInfo,
1920
} from '@temporalio/common';
2021
import { SymbolBasedInstanceOfError } from '@temporalio/common/lib/type-helpers';
2122
import { makeProtoEnumConverters } from '@temporalio/common/lib/internal-workflow/enums-helpers';
@@ -398,6 +399,17 @@ export interface ContinueAsNewOptions {
398399
* @experimental Versioning semantics with continue-as-new are experimental and may change in the future.
399400
*/
400401
initialVersioningBehavior?: InitialVersioningBehavior;
402+
403+
/**
404+
* Input type information for the next Workflow run.
405+
*
406+
* When continuing as new to the same Workflow type, its definition-supplied input type information is reused.
407+
* The next Workflow must preserve the current Workflow's output contract because clients retain the original
408+
* handle's output type information across the run chain.
409+
*
410+
* @experimental
411+
*/
412+
typeInfo?: Pick<PayloadTypeInfo, 'inputTypes'>;
401413
}
402414

403415
/**
@@ -554,6 +566,13 @@ export const [encodeParentClosePolicy, decodeParentClosePolicy] = makeProtoEnumC
554566
);
555567

556568
export interface ChildWorkflowOptions extends Omit<CommonWorkflowOptions, 'workflowIdConflictPolicy'> {
569+
/**
570+
* Type information used to encode and decode Child Workflow input and output.
571+
*
572+
* @experimental
573+
*/
574+
typeInfo?: PayloadTypeInfo;
575+
557576
/**
558577
* Workflow id to use when starting. If not specified a UUID is generated. Note that it is
559578
* dangerous as in case of client side retries no deduplication will happen based on the

packages/workflow/src/internals.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
WorkflowDefinitionOptions,
1717
WorkflowSerializationContext,
1818
PayloadTypeInfo,
19+
TypeInfo,
1920
} from '@temporalio/common';
2021
import {
2122
defaultFailureConverter,
@@ -116,6 +117,7 @@ export interface Completion<Success, Context = never> {
116117
resolve(val: Success): void;
117118
reject(reason: Error): void;
118119
context?: Context;
120+
outputTypeInfo?: TypeInfo;
119121
}
120122

121123
export interface Condition {
@@ -755,10 +757,15 @@ export class Activator implements ActivationHandler {
755757
if (!activation.result) {
756758
throw new TypeError('Got ResolveChildWorkflowExecution activation with no result');
757759
}
758-
const { resolve, reject, context } = this.consumeCompletion('childWorkflowComplete', getSeq(activation));
760+
const { resolve, reject, context, outputTypeInfo } = this.consumeCompletion(
761+
'childWorkflowComplete',
762+
getSeq(activation)
763+
);
759764
if (activation.result.completed) {
760765
const completed = activation.result.completed;
761-
const result = completed.result ? this.payloadConverter.fromPayload(completed.result, context) : undefined;
766+
const result = completed.result
767+
? fromPayloadsAtIndex(this.payloadConverter, 0, [completed.result], context, outputTypeInfo)
768+
: undefined;
762769
resolve(result);
763770
} else if (activation.result.failed) {
764771
const { failure } = activation.result.failed;

packages/workflow/src/workflow.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
compilePriority,
2525
encodeActivityCancellationType,
2626
encodeWorkflowIdReusePolicy,
27-
extractWorkflowType,
27+
extractWorkflowTypeAndConfig,
2828
HandlerUnfinishedPolicy,
2929
mapToPayloads,
3030
encodeInitialVersioningBehavior,
@@ -435,7 +435,7 @@ function startChildWorkflowExecutionNextHandler({
435435
seq,
436436
workflowId,
437437
workflowType,
438-
input: toPayloadsWithContext(activator.payloadConverter, context, options.args),
438+
input: toPayloadsWithContext(activator.payloadConverter, context, options.args, options.typeInfo?.inputTypes),
439439
retryPolicy: options.retry ? compileRetryPolicy(options.retry) : undefined,
440440
taskQueue: options.taskQueue || activator.info.taskQueue,
441441
workflowExecutionTimeout: msOptionalToTs(options.workflowExecutionTimeout),
@@ -478,6 +478,7 @@ function startChildWorkflowExecutionNextHandler({
478478
resolve,
479479
reject,
480480
context,
481+
outputTypeInfo: options.typeInfo?.outputType,
481482
});
482483
});
483484
untrackPromise(startPromise);
@@ -875,22 +876,29 @@ export async function startChild<T extends Workflow>(
875876
'Workflow.startChild(...) may only be used from a Workflow Execution. Consider using Client.workflow.start(...) instead.)'
876877
);
877878
const optionsWithDefaults = addDefaultWorkflowOptions(options ?? ({} as any));
878-
const workflowType = extractWorkflowType(workflowTypeOrFunc);
879+
const { type: workflowType, typeInfo } = extractWorkflowTypeAndConfig(
880+
workflowTypeOrFunc,
881+
optionsWithDefaults.typeInfo
882+
);
883+
const workflowOptions = {
884+
...optionsWithDefaults,
885+
typeInfo,
886+
};
879887
const execute = composeInterceptors(
880888
activator.interceptors.outbound,
881889
'startChildWorkflowExecution',
882890
startChildWorkflowExecutionNextHandler
883891
);
884892
const [started, completed] = await execute({
885893
seq: activator.nextSeqs.childWorkflow++,
886-
options: optionsWithDefaults,
894+
options: workflowOptions,
887895
headers: {},
888896
workflowType,
889897
});
890898
const firstExecutionRunId = await started;
891899

892900
return {
893-
workflowId: optionsWithDefaults.workflowId,
901+
workflowId: workflowOptions.workflowId,
894902
firstExecutionRunId,
895903
async result(): Promise<WorkflowResultType<T>> {
896904
return (await completed) as any;
@@ -906,7 +914,7 @@ export async function startChild<T extends Workflow>(
906914
args,
907915
target: {
908916
type: 'child',
909-
childWorkflowId: optionsWithDefaults.workflowId,
917+
childWorkflowId: workflowOptions.workflowId,
910918
},
911919
headers: {},
912920
});
@@ -976,15 +984,22 @@ export async function executeChild<T extends Workflow>(
976984
'Workflow.executeChild(...) may only be used from a Workflow Execution. Consider using Client.workflow.execute(...) instead.'
977985
);
978986
const optionsWithDefaults = addDefaultWorkflowOptions(options ?? ({} as any));
979-
const workflowType = extractWorkflowType(workflowTypeOrFunc);
987+
const { type: workflowType, typeInfo } = extractWorkflowTypeAndConfig(
988+
workflowTypeOrFunc,
989+
optionsWithDefaults.typeInfo
990+
);
991+
const workflowOptions = {
992+
...optionsWithDefaults,
993+
typeInfo,
994+
};
980995
const execute = composeInterceptors(
981996
activator.interceptors.outbound,
982997
'startChildWorkflowExecution',
983998
startChildWorkflowExecutionNextHandler
984999
);
9851000
const execPromise = execute({
9861001
seq: activator.nextSeqs.childWorkflow++,
987-
options: optionsWithDefaults,
1002+
options: workflowOptions,
9881003
headers: {},
9891004
workflowType,
9901005
});
@@ -1068,13 +1083,19 @@ export function makeContinueAsNewFunc<F extends Workflow>(
10681083
...rest,
10691084
};
10701085

1086+
let resolvedTypeInfo = options?.typeInfo;
1087+
// Reuse current workflow type information only when continuing as new to the same Workflow type.
1088+
if (resolvedTypeInfo == null && requiredOptions.workflowType === info.workflowType) {
1089+
resolvedTypeInfo = activator.typeInfo;
1090+
}
1091+
10711092
return (...args: Parameters<F>): Promise<never> => {
10721093
const context = currentWorkflowSerializationContext(info);
10731094
const fn = composeInterceptors(activator.interceptors.outbound, 'continueAsNew', async (input) => {
10741095
const { headers, args, options } = input;
10751096
throw new ContinueAsNew({
10761097
workflowType: options.workflowType,
1077-
arguments: toPayloadsWithContext(activator.payloadConverter, context, args),
1098+
arguments: toPayloadsWithContext(activator.payloadConverter, context, args, resolvedTypeInfo?.inputTypes),
10781099
headers,
10791100
taskQueue: options.taskQueue,
10801101
memo: options.memo && mapToPayloads(activator.payloadConverter, options.memo, context),

0 commit comments

Comments
 (0)