Skip to content

Commit c219b68

Browse files
committed
Support Workflow Update as a Nexus Operation
1 parent 1312bd7 commit c219b68

12 files changed

Lines changed: 991 additions & 5 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ to docs, or any other relevant information.
2121

2222
### Added
2323

24+
- UpdateWorkflow-backed Nexus operations. A Temporal Nexus operation can now be backed by a Workflow
25+
Update via `TemporalNexusClient.updateWorkflow`, in addition to a Workflow run. The Update request
26+
carries the Nexus request ID (for deduplication), the request links, and a completion callback
27+
bearing the operation token, so the Update's completion is delivered back to the Nexus caller. Only
28+
asynchronous, `ACCEPTED`-stage updates are supported (a callback URL is required); an update that
29+
has already completed is returned synchronously, and a completed-with-error update (e.g. a
30+
validation rejection) surfaces as a failed Nexus operation. Cancellation is customizable via the
31+
`cancelWorkflowUpdate` handler option; the default rejects with a `NOT_IMPLEMENTED` handler error.
2432
- Nexus operation link propagation for signals. When a Nexus operation handler signals a workflow
2533
(including signal-with-start), the inbound Nexus request links are now forwarded onto the signaled
2634
workflow so its history events link back to the caller, and the link the server returns for the

packages/client/src/internal.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,51 @@ export interface InternalWorkflowStartOptions extends WorkflowOptions {
4949
};
5050
}
5151

52+
/**
53+
* A symbol used to attach extra, SDK-internal options to a `WorkflowHandle.startUpdate()` /
54+
* `executeUpdate()` call.
55+
*
56+
* Used by the Temporal Nexus helpers to attach a completion callback, request ID, and request links
57+
* onto the UpdateWorkflowExecution request, and to capture the response link the server returns.
58+
*
59+
* @internal
60+
* @hidden
61+
*/
62+
export const InternalWorkflowUpdateOptionsSymbol = Symbol.for('__temporal_internal_client_workflow_update_options');
63+
export interface InternalWorkflowUpdateOptions {
64+
[InternalWorkflowUpdateOptionsSymbol]?: {
65+
/**
66+
* Request ID used for server-side deduplication of the Update request.
67+
*/
68+
requestId?: string;
69+
70+
/**
71+
* Callbacks to be invoked by the server when the Update reaches a terminal state.
72+
* Callback addresses must be whitelisted in the server's dynamic configuration.
73+
*/
74+
completionCallbacks?: temporal.api.common.v1.ICallback[];
75+
76+
/**
77+
* Links to be associated with the Update.
78+
*/
79+
links?: temporal.api.common.v1.ILink[];
80+
81+
/**
82+
* Response link copied by the client from the UpdateWorkflowExecutionResponse. On success it
83+
* points at the Update Accepted event; on validation failure it may be a plain Workflow link.
84+
* Only populated by servers that return an update response link; left unset otherwise.
85+
*/
86+
responseLink?: temporal.api.common.v1.ILink;
87+
88+
/**
89+
* Terminal outcome of the Update, copied by the client from the UpdateWorkflowExecutionResponse
90+
* when the Update already reached a completed state (e.g. a retried request with the same update
91+
* ID, or an Update that completes immediately). Left unset while the Update is only accepted.
92+
*/
93+
outcome?: temporal.api.update.v1.IOutcome;
94+
};
95+
}
96+
5297
/**
5398
* A symbol used to attach extra, SDK-internal options to a `WorkflowHandle.signal()` call.
5499
*

packages/client/src/workflow-client.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,12 @@ import { BaseClient, defaultBaseClientOptions } from './base-client';
9494
import { mapAsyncIterable } from './iterators-utils';
9595
import { WorkflowUpdateStage, encodeWorkflowUpdateStage } from './workflow-update-stage';
9696
import type { InternalWorkflowHandle, InternalWorkflowSignalInput, InternalWorkflowStartOptions } from './internal';
97-
import { InternalWorkflowSignalOptionsSymbol, InternalWorkflowStartOptionsSymbol } from './internal';
97+
import {
98+
InternalWorkflowSignalOptionsSymbol,
99+
InternalWorkflowStartOptionsSymbol,
100+
type InternalWorkflowUpdateOptions,
101+
InternalWorkflowUpdateOptionsSymbol,
102+
} from './internal';
98103
import { adaptWorkflowClientInterceptor } from './interceptor-adapters';
99104

100105
const UpdateWorkflowExecutionLifecycleStage = temporal.api.enums.v1.UpdateWorkflowExecutionLifecycleStage;
@@ -994,6 +999,9 @@ export class WorkflowClient extends BaseClient {
994999
const dataConverter = this.dataConverter;
9951000
const context = this.workflowSerializationContext(input.workflowExecution.workflowId!);
9961001
const updateId = input.options?.updateId ?? randomUUID();
1002+
const internalOptions = (input.options as InternalWorkflowUpdateOptions | undefined)?.[
1003+
InternalWorkflowUpdateOptionsSymbol
1004+
];
9971005
return {
9981006
namespace: this.options.namespace,
9991007
workflowExecution: input.workflowExecution,
@@ -1011,6 +1019,9 @@ export class WorkflowClient extends BaseClient {
10111019
name: input.updateName,
10121020
args: { payloads: await encodeToPayloadsWithContext(dataConverter, context, input.args) },
10131021
},
1022+
requestId: internalOptions?.requestId,
1023+
completionCallbacks: internalOptions?.completionCallbacks,
1024+
links: internalOptions?.links,
10141025
},
10151026
};
10161027
}
@@ -1048,6 +1059,17 @@ export class WorkflowClient extends BaseClient {
10481059
} catch (err) {
10491060
this.rethrowUpdateGrpcError(err, 'Workflow Update failed', input.workflowExecution);
10501061
}
1062+
const internalOptions = (input.options as InternalWorkflowUpdateOptions | undefined)?.[
1063+
InternalWorkflowUpdateOptionsSymbol
1064+
];
1065+
if (internalOptions != null) {
1066+
// Capture the link the server attached to the Update response so the Nexus helper can add it
1067+
// as a handler link. Older servers leave it unset.
1068+
internalOptions.responseLink = response.link ?? undefined;
1069+
// Capture the terminal outcome (if any) so the Nexus helper can distinguish an already-completed
1070+
// Update (return a synchronous result) from one that is merely accepted (return async).
1071+
internalOptions.outcome = response.outcome ?? undefined;
1072+
}
10511073
return {
10521074
updateId: request.request!.meta!.updateId!,
10531075

packages/nexus/src/__tests__/test-nexus-link-converter.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@ import test from 'ava';
22
import Long from 'long';
33
import { temporal } from '@temporalio/proto';
44
import {
5+
convertCommonLinkToNexusLink,
56
convertNexusLinkToTemporalLink,
67
convertNexusLinkToWorkflowEventLink,
78
convertNexusOperationLinkToNexusLink,
89
convertTemporalLinkToNexusLink,
910
convertWorkflowEventLinkToNexusLink,
11+
convertWorkflowLinkToNexusLink,
1012
} from '../link-converter';
1113

1214
const { EventType } = temporal.api.enums.v1;
1315
const WORKFLOW_EVENT_TYPE = (temporal.api.common.v1.Link.WorkflowEvent as any).fullName.slice(1);
1416
const NEXUS_OPERATION_TYPE = (temporal.api.common.v1.Link.NexusOperation as any).fullName.slice(1);
17+
const WORKFLOW_TYPE = (temporal.api.common.v1.Link.Workflow as any).fullName.slice(1);
1518

1619
function makeEventRef(eventId: number, eventType: keyof typeof EventType) {
1720
return {
@@ -107,6 +110,47 @@ test('convertNexusOperationLinkToNexusLink escapes URL path components', (t) =>
107110
});
108111
});
109112

113+
test('convertWorkflowLinkToNexusLink produces a history URL with the Workflow link type', (t) => {
114+
const nexusLink = convertWorkflowLinkToNexusLink({
115+
namespace: 'ns',
116+
workflowId: 'wid',
117+
runId: 'rid',
118+
});
119+
t.is(nexusLink.type, WORKFLOW_TYPE);
120+
t.is(nexusLink.url.toString(), 'temporal:///namespaces/ns/workflows/wid/rid/history');
121+
});
122+
123+
test('convertWorkflowLinkToNexusLink escapes URL path components', (t) => {
124+
const nexusLink = convertWorkflowLinkToNexusLink({
125+
namespace: 'name/space',
126+
workflowId: 'work id',
127+
runId: 'run/id',
128+
});
129+
t.is(nexusLink.url.toString(), 'temporal:///namespaces/name%2Fspace/workflows/work%20id/run%2Fid/history');
130+
});
131+
132+
test('convertWorkflowLinkToNexusLink throws on missing required fields', (t) => {
133+
t.throws(() => convertWorkflowLinkToNexusLink({ namespace: '', workflowId: 'wid', runId: 'rid' }), {
134+
instanceOf: TypeError,
135+
});
136+
t.throws(() => convertWorkflowLinkToNexusLink({ namespace: 'ns', workflowId: '', runId: 'rid' }), {
137+
instanceOf: TypeError,
138+
});
139+
});
140+
141+
test('convertCommonLinkToNexusLink dispatches by variant, preferring workflowEvent', (t) => {
142+
const workflowEvent = {
143+
namespace: 'ns',
144+
workflowId: 'wid',
145+
runId: 'rid',
146+
eventRef: makeEventRef(42, 'EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_ACCEPTED'),
147+
};
148+
t.is(convertCommonLinkToNexusLink({ workflowEvent }).type, WORKFLOW_EVENT_TYPE);
149+
150+
const workflowLink = { namespace: 'ns', workflowId: 'wid', runId: 'rid' };
151+
t.is(convertCommonLinkToNexusLink({ workflow: workflowLink }).type, WORKFLOW_TYPE);
152+
});
153+
110154
test('convertTemporalLinkToNexusLink dispatches by Temporal link variant', (t) => {
111155
const workflowEvent = {
112156
namespace: 'ns',

packages/nexus/src/__tests__/test-nexus-token-helpers.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import test from 'ava';
22
import {
33
base64URLEncodeNoPadding,
4+
generateUpdateWorkflowOperationToken,
45
generateWorkflowRunOperationToken,
56
loadOperationToken,
7+
loadUpdateWorkflowOperationToken,
68
loadWorkflowRunOperationToken,
79
} from '../token';
810

@@ -47,3 +49,69 @@ test('decode workflow run Operation token errors', (t) => {
4749
message: /invalid workflow run token: missing workflow ID \(wid\)/,
4850
});
4951
});
52+
53+
test('encode and decode update workflow Operation token', (t) => {
54+
const expected = {
55+
t: 3,
56+
ns: 'ns',
57+
wid: 'w',
58+
rid: 'r',
59+
uid: 'u',
60+
};
61+
const token = generateUpdateWorkflowOperationToken('ns', 'w', 'r', 'u');
62+
const decoded = loadUpdateWorkflowOperationToken(token);
63+
t.deepEqual(decoded, expected);
64+
});
65+
66+
test('update workflow Operation token matches Go wire format byte-for-byte', (t) => {
67+
// Field order and names must match the Go SDK's json.Marshal output: t, ns, wid, rid, uid.
68+
// The version field ("v") must be absent.
69+
const token = generateUpdateWorkflowOperationToken('ns', 'w', 'r', 'u');
70+
const decodedJSON = Buffer.from(token, 'base64url').toString('utf-8');
71+
t.is(decodedJSON, '{"t":3,"ns":"ns","wid":"w","rid":"r","uid":"u"}');
72+
});
73+
74+
test('encode update workflow Operation token with empty runId omits rid', (t) => {
75+
const token = generateUpdateWorkflowOperationToken('ns', 'w', '', 'u');
76+
const decodedJSON = Buffer.from(token, 'base64url').toString('utf-8');
77+
// `rid` is optional: it must be omitted entirely rather than emitted as an empty string.
78+
t.is(decodedJSON, '{"t":3,"ns":"ns","wid":"w","uid":"u"}');
79+
80+
const parsed = JSON.parse(decodedJSON);
81+
t.false('rid' in parsed);
82+
83+
// Round-trips: the load side tolerates an absent rid, yielding no pinned run.
84+
const decoded = loadUpdateWorkflowOperationToken(token);
85+
t.deepEqual(decoded, { t: 3, ns: 'ns', wid: 'w', uid: 'u' });
86+
t.is(decoded.rid, undefined);
87+
});
88+
89+
test('load update workflow Operation token tolerates legacy empty rid', (t) => {
90+
// Legacy tokens may carry rid:"" explicitly; it must be accepted and treated as no pinned run.
91+
const legacyToken = base64URLEncodeNoPadding('{"t":3,"ns":"ns","wid":"w","rid":"","uid":"u"}');
92+
const decoded = loadUpdateWorkflowOperationToken(legacyToken);
93+
t.deepEqual(decoded, { t: 3, ns: 'ns', wid: 'w', rid: '', uid: 'u' });
94+
});
95+
96+
test('generate update workflow Operation token validates required params', (t) => {
97+
t.throws(() => generateUpdateWorkflowOperationToken('', 'w', 'r', 'u'), { message: /namespace/ });
98+
t.throws(() => generateUpdateWorkflowOperationToken('ns', '', 'r', 'u'), { message: /workflow ID/ });
99+
t.throws(() => generateUpdateWorkflowOperationToken('ns', 'w', 'r', ''), { message: /update ID/ });
100+
});
101+
102+
test('decode update workflow Operation token errors', (t) => {
103+
const wrongTypeToken = base64URLEncodeNoPadding('{"t":1,"ns":"ns","wid":"w"}');
104+
t.throws(() => loadUpdateWorkflowOperationToken(wrongTypeToken), {
105+
message: /invalid update workflow token type: 1, expected: 3/,
106+
});
107+
108+
const missingWIDToken = base64URLEncodeNoPadding('{"t":3,"ns":"ns","uid":"u"}');
109+
t.throws(() => loadUpdateWorkflowOperationToken(missingWIDToken), {
110+
message: /invalid update workflow token: missing workflow ID \(wid\)/,
111+
});
112+
113+
const missingUIDToken = base64URLEncodeNoPadding('{"t":3,"ns":"ns","wid":"w"}');
114+
t.throws(() => loadUpdateWorkflowOperationToken(missingUIDToken), {
115+
message: /invalid update workflow token: missing update ID \(uid\)/,
116+
});
117+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import test from 'ava';
2+
import * as nexus from 'nexus-rpc';
3+
import { TemporalOperationHandler, TemporalOperationResult } from '../workflow-helpers';
4+
import { generateUpdateWorkflowOperationToken } from '../token';
5+
6+
const cancelContext: nexus.CancelOperationContext = {
7+
abortSignal: new AbortController().signal,
8+
headers: {},
9+
operation: 'operation',
10+
service: 'service',
11+
};
12+
13+
test('cancel of an UpdateWorkflow operation without a custom handler throws NOT_IMPLEMENTED', async (t) => {
14+
const handler = new TemporalOperationHandler({
15+
async start() {
16+
return TemporalOperationResult.sync(undefined);
17+
},
18+
});
19+
const token = generateUpdateWorkflowOperationToken('ns', 'wid', 'rid', 'uid');
20+
21+
const err = await t.throwsAsync(() => handler.cancel(cancelContext, token));
22+
t.true(err instanceof nexus.HandlerError);
23+
t.is((err as nexus.HandlerError).type, 'NOT_IMPLEMENTED');
24+
t.regex(err?.message ?? '', /cannot cancel an UpdateWorkflow operation/);
25+
});
26+
27+
test('cancel of an UpdateWorkflow operation routes to the custom cancelWorkflowUpdate handler', async (t) => {
28+
let received: { workflowId: string; runId: string; updateId: string } | undefined;
29+
const handler = new TemporalOperationHandler({
30+
async start() {
31+
return TemporalOperationResult.sync(undefined);
32+
},
33+
async cancelWorkflowUpdate(_ctx, options) {
34+
received = options;
35+
},
36+
async cancelWorkflowRun() {
37+
throw new Error('cancelWorkflowRun should not be called for an UpdateWorkflow token');
38+
},
39+
});
40+
const token = generateUpdateWorkflowOperationToken('ns', 'wid', 'rid', 'uid');
41+
42+
await handler.cancel(cancelContext, token);
43+
t.deepEqual(received, { workflowId: 'wid', runId: 'rid', updateId: 'uid' });
44+
});
45+
46+
test('cancel rejects a malformed UpdateWorkflow operation token', async (t) => {
47+
const handler = new TemporalOperationHandler({
48+
async start() {
49+
return TemporalOperationResult.sync(undefined);
50+
},
51+
async cancelWorkflowUpdate() {
52+
throw new Error('cancelWorkflowUpdate should not be called for an invalid token');
53+
},
54+
});
55+
// Type 3 (UpdateWorkflow) but missing the required update ID.
56+
const token = Buffer.from(JSON.stringify({ t: 3, ns: 'ns', wid: 'wid' })).toString('base64url');
57+
58+
const err = await t.throwsAsync(() => handler.cancel(cancelContext, token));
59+
t.true(err instanceof nexus.HandlerError);
60+
t.is((err as nexus.HandlerError).type, 'BAD_REQUEST');
61+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import test from 'ava';
2+
import * as nexus from 'nexus-rpc';
3+
import { WorkflowUpdateStage } from '@temporalio/client';
4+
import { TemporalOperationHandler } from '../workflow-helpers';
5+
6+
/**
7+
* Builds a minimal {@link nexus.StartOperationContext} for driving a {@link TemporalOperationHandler}
8+
* start handler directly. The `updateWorkflow` input-validation guards run before any Temporal Client
9+
* or handler context is touched, so these paths can be exercised without a live worker.
10+
*/
11+
function makeStartContext(overrides: Partial<nexus.StartOperationContext> = {}): nexus.StartOperationContext {
12+
return {
13+
service: 'service',
14+
operation: 'operation',
15+
headers: {},
16+
abortSignal: new AbortController().signal,
17+
requestId: 'request-id',
18+
inboundLinks: [],
19+
outboundLinks: [],
20+
...overrides,
21+
};
22+
}
23+
24+
test('updateWorkflow rejects a non-ACCEPTED waitForStage as a failed operation', async (t) => {
25+
const handler = new TemporalOperationHandler<undefined, number>({
26+
async start(_ctx, client) {
27+
return await client.updateWorkflow<number>({
28+
workflowId: 'wid',
29+
update: 'someUpdate',
30+
// Only ACCEPTED is supported for async Nexus Update operations.
31+
waitForStage: WorkflowUpdateStage.COMPLETED,
32+
});
33+
},
34+
});
35+
36+
// A callback URL is present so the failure is attributable to the wait stage, not the missing URL.
37+
const err = await t.throwsAsync(() =>
38+
handler.start(makeStartContext({ callbackUrl: 'http://callback' }), undefined)
39+
);
40+
t.true(err instanceof nexus.OperationError);
41+
t.is((err as nexus.OperationError).state, 'failed');
42+
t.regex(err?.message ?? '', /ACCEPTED wait stage/);
43+
});
44+
45+
test('updateWorkflow without a callback URL fails with a BAD_REQUEST handler error', async (t) => {
46+
const handler = new TemporalOperationHandler<undefined, number>({
47+
async start(_ctx, client) {
48+
return await client.updateWorkflow<number>({
49+
workflowId: 'wid',
50+
update: 'someUpdate',
51+
// waitForStage defaults to ACCEPTED, so the missing callback URL is what fails the call.
52+
});
53+
},
54+
});
55+
56+
const err = await t.throwsAsync(() => handler.start(makeStartContext(), undefined));
57+
t.true(err instanceof nexus.HandlerError);
58+
t.is((err as nexus.HandlerError).type, 'BAD_REQUEST');
59+
t.regex(err?.message ?? '', /callback URL is required/);
60+
});

packages/nexus/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export {
1919
startWorkflow,
2020
signalWithStartWorkflow,
2121
type CancelWorkflowRunOptions,
22+
type CancelWorkflowUpdateOptions,
23+
type NexusUpdateWorkflowOptions,
2224
type TemporalOperationHandlerOptions,
2325
TemporalOperationHandler,
2426
TemporalOperationResult,

0 commit comments

Comments
 (0)