@temporalio/workflow
Version:
Temporal.io SDK Workflow sub-package
1,324 lines (1,190 loc) • 56.5 kB
text/typescript
import type { AsyncLocalStorage as ALS } from 'node:async_hooks';
import type { RawSourceMap } from 'source-map';
import type {
ActivitySerializationContext,
FailureConverter,
PayloadConverter,
ProtoFailure,
Workflow,
WorkflowFunctionWithOptions,
WorkflowQueryAnnotatedType,
WorkflowSignalAnnotatedType,
WorkflowUpdateAnnotatedType,
WorkflowUpdateType,
WorkflowUpdateValidatorType,
VersioningBehavior,
WorkflowDefinitionOptions,
WorkflowSerializationContext,
} from '@temporalio/common';
import {
defaultFailureConverter,
arrayFromPayloads,
defaultPayloadConverter,
ensureTemporalFailure,
HandlerUnfinishedPolicy,
IllegalStateError,
TemporalFailure,
WorkflowExecutionAlreadyStartedError,
ApplicationFailure,
mapFromPayloads,
fromPayloadsAtIndex,
RawValue,
} from '@temporalio/common';
import {
decodeSearchAttributes,
decodeTypedSearchAttributes,
} from '@temporalio/common/lib/converter/payload-search-attributes';
import { makeProtoEnumConverters } from '@temporalio/common/lib/internal-workflow';
import type { coresdk, temporal } from '@temporalio/proto';
import {
TEMPORAL_RESERVED_PREFIX,
STACK_TRACE_QUERY_NAME,
ENHANCED_STACK_TRACE_QUERY_NAME,
} from '@temporalio/common/lib/reserved';
import type { RNG } from './alea';
import { alea } from './alea';
import { RootCancellationScope } from './cancellation-scope';
import { composeInterceptors } from './interceptor-composition';
import { AsyncLocalStorage, UpdateScope } from './update-scope';
import { deriveAleaSeed } from './random-stream-seed';
import { DeterminismViolationError, LocalActivityDoBackoff, isCancellation } from './errors';
import type {
QueryInput,
SignalInput,
StartNexusOperationOutput,
UpdateInput,
WorkflowExecuteInput,
WorkflowInterceptors,
} from './interceptors';
import type {
DefaultSignalHandler,
StackTraceSDKInfo,
StackTraceFileSlice,
StackTraceFileLocation,
WorkflowInfo,
WorkflowCreateOptionsInternal,
ActivationCompletion,
DefaultUpdateHandler,
DefaultQueryHandler,
EnhancedStackTrace,
} from './interfaces';
import { ContinueAsNew } from './interfaces';
import { type SinkCall } from './sinks';
import { untrackPromise } from './stack-helpers';
import pkg from './pkg';
import type { SdkFlag } from './flags';
import { assertValidFlag } from './flags';
import { executeWithLifecycleLogging, log } from './logs';
const StartChildWorkflowExecutionFailedCause = {
WORKFLOW_ALREADY_EXISTS: 'WORKFLOW_ALREADY_EXISTS',
} as const;
type StartChildWorkflowExecutionFailedCause =
(typeof StartChildWorkflowExecutionFailedCause)[keyof typeof StartChildWorkflowExecutionFailedCause];
const [_encodeStartChildWorkflowExecutionFailedCause, decodeStartChildWorkflowExecutionFailedCause] =
makeProtoEnumConverters<
coresdk.child_workflow.StartChildWorkflowExecutionFailedCause,
typeof coresdk.child_workflow.StartChildWorkflowExecutionFailedCause,
keyof typeof coresdk.child_workflow.StartChildWorkflowExecutionFailedCause,
typeof StartChildWorkflowExecutionFailedCause,
'START_CHILD_WORKFLOW_EXECUTION_FAILED_CAUSE_'
>(
{
[StartChildWorkflowExecutionFailedCause.WORKFLOW_ALREADY_EXISTS]: 1,
UNSPECIFIED: 0,
} as const,
'START_CHILD_WORKFLOW_EXECUTION_FAILED_CAUSE_'
);
export interface Stack {
formatted: string;
structured: StackTraceFileLocation[];
}
/**
* Global store to track promise stacks for stack trace query
*/
export interface PromiseStackStore {
childToParent: Map<Promise<unknown>, Set<Promise<unknown>>>;
promiseToStack: Map<Promise<unknown>, Stack>;
}
export interface Completion<Success, Context = never> {
resolve(val: Success): void;
reject(reason: Error): void;
context?: Context;
}
export interface Condition {
fn(): boolean;
resolve(): void;
}
export type ActivationHandlerFunction<K extends keyof coresdk.workflow_activation.IWorkflowActivationJob> = (
activation: NonNullable<coresdk.workflow_activation.IWorkflowActivationJob[K]>
) => void;
/**
* Verifies all activation job handling methods are implemented
*/
export type ActivationHandler = {
[P in keyof coresdk.workflow_activation.IWorkflowActivationJob]: ActivationHandlerFunction<P>;
};
/**
* Information about an update or signal handler execution.
*/
interface MessageHandlerExecution {
name: string;
unfinishedPolicy: HandlerUnfinishedPolicy;
id?: string;
}
type InferMapValue<T> = T extends Map<number, infer V> ? V : never;
interface ScopedWorkflowRandomSource {
random(): number;
}
/**
* Keeps all of the Workflow runtime state like pending completions for activities and timers.
*
* Implements handlers for all workflow activation jobs.
*
* Note that most methods in this class are meant to be called only from within the VM.
*
* However, a few methods may be called directly from outside the VM (essentially from `vm-shared.ts`).
* These methods are specifically marked with a comment and require careful consideration, as the
* execution context may not properly reflect that of the target workflow execution (e.g.: with Reusable
* VMs, the `global` may not have been swapped to those of that workflow execution; the active microtask
* queue may be that of the thread/process, rather than the queue of that VM context; etc). Consequently,
* methods that are meant to be called from outside of the VM must not do any of the following:
*
* - Access any global variable;
* - Create Promise objects, use async/await, or otherwise schedule microtasks;
* - Call user-defined functions, including any form of interceptor.
*/
export class Activator implements ActivationHandler {
/**
* Cache for modules - referenced in reusable-vm.ts
*/
readonly moduleCache = new Map<string, unknown>();
/**
* Map of task sequence to a Completion
*/
readonly completions = {
timer: new Map<number, Completion<void>>(),
activity: new Map<number, Completion<unknown, ActivitySerializationContext>>(),
nexusOperationStart: new Map<number, Completion<StartNexusOperationOutput>>(),
nexusOperationComplete: new Map<number, Completion<unknown>>(),
childWorkflowStart: new Map<number, Completion<string, WorkflowSerializationContext>>(),
childWorkflowComplete: new Map<number, Completion<unknown, WorkflowSerializationContext>>(),
signalWorkflow: new Map<number, Completion<void, WorkflowSerializationContext>>(),
cancelWorkflow: new Map<number, Completion<void, WorkflowSerializationContext>>(),
};
/**
* Holds buffered Update calls until a handler is registered
*/
readonly bufferedUpdates = Array<coresdk.workflow_activation.IDoUpdate>();
/**
* Holds buffered signal calls until a handler is registered
*/
readonly bufferedSignals = Array<coresdk.workflow_activation.ISignalWorkflow>();
/**
* Mapping of update name to handler and validator
*/
readonly updateHandlers = new Map<string, WorkflowUpdateAnnotatedType>();
/**
* Mapping of signal name to handler
*/
readonly signalHandlers = new Map<string, WorkflowSignalAnnotatedType>();
/**
* Mapping of in-progress updates to handler execution information.
*/
readonly inProgressUpdates = new Map<string, MessageHandlerExecution>();
/**
* Mapping of in-progress signals to handler execution information.
*/
readonly inProgressSignals = new Map<number, MessageHandlerExecution>();
/**
* A sequence number providing unique identifiers for signal handler executions.
*/
protected signalHandlerExecutionSeq = 0;
/**
* A signal handler that catches calls for non-registered signal names.
*/
defaultSignalHandler?: DefaultSignalHandler;
/**
* A update handler that catches calls for non-registered update names.
*/
defaultUpdateHandler?: DefaultUpdateHandler;
/**
* A query handler that catches calls for non-registered query names.
*/
defaultQueryHandler?: DefaultQueryHandler;
/**
* Source map file for looking up the source files in response to __enhanced_stack_trace
*/
protected readonly sourceMap: RawSourceMap;
/**
* Whether or not to send the sources in enhanced stack trace query responses
*/
protected readonly showStackTraceSources;
readonly promiseStackStore: PromiseStackStore = {
promiseToStack: new Map(),
childToParent: new Map(),
};
/**
* The error that caused the current Workflow Task to fail. Sets if a non-`TemporalFailure`
* error bubbles up out of the Workflow function, or out of a Signal or Update handler. We
* capture errors this way because those functions are not technically awaited when started,
* but left to run asynchronously. There is therefore no real "parent" function that can
* directly handle those errors, and not capturing it would result in an Unhandled Promise
* Rejection. So instead, we buffer the error here, to then be processed in the context
* of our own synchronous Activation handling event loop.
*
* Our code does a best effort to stop processing the current activation as soon as possible
* after this field is set:
* - If an error is thrown while executing code synchronously (e.g. anything before the
* first `await` statement in a Workflow function or a signal/update handler), the error
* will be _immediately_ rethrown, which will prevent execution of further jobs in the
* current activation. We know we're currently running code synchronously thanks to the
* `rethrowSynchronously` flag below.
* - It an error is thrown while executing microtasks, then the error will be rethrown on
* the next call to `tryUnblockConditions()`.
*
* Unfortunately, there's no way for us to prevent further execution of microtasks that have
* already been scheduled, nor those that will be recursively scheduled from those microtasks.
* Should more errors get thrown while settling microtasks, those will be ignored (i.e. only
* the first captured error is preserved).
*/
public workflowTaskError: unknown;
/**
* Error type _names_ (from {@link WorkerOptions.workflowFailureErrorTypes}) that
* should cause Workflow Execution failure rather than WFT failure.
*
* Set at workflow creation time from the worker options.
*/
public failureExceptionTypeNames: string[] = [];
/**
* Error _types_ (from {@link WorkflowDefinitionOptions.failureExceptionTypes})
* that should cause Workflow Execution failure rather than WFT failure.
*
* Set in `worker-interface.ts` after the workflow definition options are read.
*/
public workflowDefinitionFailureExceptionTypes: Array<new (...args: any[]) => Error> | undefined = undefined;
/**
* Set to true when running synchronous code (e.g. while processing activation jobs and when calling
* `tryUnblockConditions()`). While this flag is set, it is safe to let errors bubble up.
*/
public rethrowSynchronously = false;
public readonly rootScope = new RootCancellationScope();
/**
* Mapping of query name to handler
*/
public readonly queryHandlers = new Map<string, WorkflowQueryAnnotatedType>([
[
STACK_TRACE_QUERY_NAME,
{
handler: () => {
return new RawValue<string>(
this.getStackTraces()
.map((s) => s.formatted)
.join('\n\n')
);
},
description: 'Returns a sensible stack trace.',
},
],
[
ENHANCED_STACK_TRACE_QUERY_NAME,
{
handler: (): RawValue => {
const { sourceMap } = this;
const sdk: StackTraceSDKInfo = { name: 'typescript', version: pkg.version };
const stacks = this.getStackTraces().map(({ structured: locations }) => ({ locations }));
const sources: Record<string, StackTraceFileSlice[]> = {};
if (this.showStackTraceSources) {
for (const { locations } of stacks) {
for (const { file_path } of locations) {
if (!file_path) continue;
const content = sourceMap?.sourcesContent?.[sourceMap?.sources.indexOf(file_path)];
if (!content) continue;
sources[file_path] = [
{
line_offset: 0,
content,
},
];
}
}
}
return new RawValue<EnhancedStackTrace>({ sdk, stacks, sources });
},
description: 'Returns a stack trace annotated with source information.',
},
],
[
'__temporal_workflow_metadata',
{
handler: (): RawValue => {
const workflowType = this.info.workflowType;
const queryDefinitions = Array.from(this.queryHandlers.entries()).map(([name, value]) => ({
name,
description: value.description,
}));
const signalDefinitions = Array.from(this.signalHandlers.entries()).map(([name, value]) => ({
name,
description: value.description,
}));
const updateDefinitions = Array.from(this.updateHandlers.entries()).map(([name, value]) => ({
name,
description: value.description,
}));
return new RawValue<temporal.api.sdk.v1.IWorkflowMetadata>({
definition: {
type: workflowType,
queryDefinitions,
signalDefinitions,
updateDefinitions,
},
currentDetails: this.currentDetails,
});
},
description: 'Returns metadata associated with this workflow.',
},
],
]);
/**
* Loaded in {@link initRuntime}
*/
public readonly interceptors: Required<WorkflowInterceptors> = {
inbound: [],
outbound: [],
internals: [],
};
/**
* Buffer that stores all generated commands, reset after each activation
*/
protected commands: coresdk.workflow_commands.IWorkflowCommand[] = [];
/**
* Stores all {@link condition}s that haven't been unblocked yet
*/
public readonly blockedConditions = new Map<number, Condition>();
/**
* Is this Workflow completed?
*
* A Workflow will be considered completed if it generates a command that the
* system considers as a final Workflow command (e.g.
* completeWorkflowExecution or failWorkflowExecution).
*/
public completed = false;
/**
* Was this Workflow cancelled?
*/
protected cancelled = false;
/**
* The next (incremental) sequence to assign when generating completable commands
*/
public nextSeqs = {
timer: 1,
activity: 1,
childWorkflow: 1,
signalWorkflow: 1,
cancelWorkflow: 1,
condition: 1,
nexusOperation: 1,
// Used internally to keep track of active stack traces
stack: 1,
};
/**
* This is set every time the workflow executes an activation
* May be accessed and modified from outside the VM.
*/
now: number;
/**
* Reference to the current Workflow, initialized when a Workflow is started
*/
public workflow?: Workflow | WorkflowFunctionWithOptions<any[], any>;
/**
* Information about the current Workflow
* May be accessed from outside the VM.
*/
public info: WorkflowInfo;
/**
* The main deterministic RNG for this workflow execution.
*
* Scoped overrides used by `WorkflowRandomStream.with(...)` are layered on top of this RNG.
*/
public random: RNG;
/**
* The current seed material for this workflow execution's deterministic RNGs.
*/
public randomnessSeed: number[];
/**
* Additional deterministic RNG streams keyed by stable stream name.
*/
public readonly namedRandomStreams = new Map<string, RNG>();
protected currentRandomStorage?: ALS<ScopedWorkflowRandomSource | undefined>;
public payloadConverter: PayloadConverter = defaultPayloadConverter;
public failureConverter: FailureConverter = defaultFailureConverter;
/**
* Patches we know the status of for this workflow, as in {@link patched}
*/
private readonly knownPresentPatches = new Set<string>();
/**
* Patches we sent to core {@link patched}
*/
private readonly sentPatches = new Set<string>();
private readonly knownFlags = new Set<number>();
sdkVersion?: string;
/**
* Buffered sink calls per activation
*/
sinkCalls = Array<SinkCall>();
/**
* A nanosecond resolution time function, externally injected. This is used to
* precisely sort logs entries emitted from the Workflow Context vs those emitted
* from other sources (e.g. main thread, Core, etc).
*/
public readonly getTimeOfDay: () => bigint;
public readonly registeredActivityNames: Set<string>;
public currentDetails: string = '';
public versioningBehavior?: VersioningBehavior;
public workflowDefinitionOptionsGetter?: () => WorkflowDefinitionOptions;
public readonly workflowSandboxDestructors: (() => void)[] = [];
protected readonly stackTracesEnabled: boolean;
constructor({
info,
now,
showStackTraceSources,
sourceMap,
getTimeOfDay,
randomnessSeed,
registeredActivityNames,
stackTracesEnabled,
failureExceptionTypeNames,
}: WorkflowCreateOptionsInternal) {
this.getTimeOfDay = getTimeOfDay;
this.info = info;
this.now = now;
this.showStackTraceSources = showStackTraceSources;
this.sourceMap = sourceMap;
this.randomnessSeed = [...randomnessSeed];
this.random = alea(this.randomnessSeed);
this.registeredActivityNames = registeredActivityNames;
this.stackTracesEnabled = stackTracesEnabled;
this.failureExceptionTypeNames = failureExceptionTypeNames ?? [];
}
protected setRandomnessSeed(randomnessSeed: number[]): void {
this.randomnessSeed = [...randomnessSeed];
this.random = alea(this.randomnessSeed);
this.namedRandomStreams.clear();
}
public getNamedRandom(name: string): RNG {
const cached = this.namedRandomStreams.get(name);
if (cached !== undefined) {
return cached;
}
const random = alea(deriveAleaSeed(this.randomnessSeed, name));
this.namedRandomStreams.set(name, random);
return random;
}
protected withRandomSource<T>(randomSource: ScopedWorkflowRandomSource | undefined, fn: () => T): T {
return (this.currentRandomStorage ??= new AsyncLocalStorage<ScopedWorkflowRandomSource | undefined>()).run(
randomSource,
fn
);
}
public withCurrentRandom<T>(randomSource: ScopedWorkflowRandomSource, fn: () => T): T {
return this.withRandomSource(randomSource, fn);
}
public bindCurrentRandom<T extends (...args: any[]) => any>(fn: T): T {
const randomSource = this.currentRandomStorage?.getStore();
return ((...args: Parameters<T>) => this.withRandomSource(randomSource, () => fn(...args))) as T;
}
public currentRandom(): number {
return this.currentRandomStorage?.getStore()?.random() ?? this.random();
}
/**
* May be invoked from outside the VM.
*/
mutateWorkflowInfo(fn: (info: WorkflowInfo) => WorkflowInfo): void {
this.info = fn(this.info);
}
protected getStackTraces(): Stack[] {
if (!this.stackTracesEnabled) {
throw new IllegalStateError('Workflow stack traces are not enabled on this worker');
}
const { childToParent, promiseToStack } = this.promiseStackStore;
const internalNodes = [...childToParent.values()].reduce((acc, curr) => {
for (const p of curr) {
acc.add(p);
}
return acc;
}, new Set());
const stacks = new Map<string, Stack>();
for (const child of childToParent.keys()) {
if (!internalNodes.has(child)) {
const stack = promiseToStack.get(child);
if (!stack || !stack.formatted) continue;
stacks.set(stack.formatted, stack);
}
}
// Not 100% sure where this comes from, just filter it out
stacks.delete(' at Promise.then (<anonymous>)');
stacks.delete(' at Promise.then (<anonymous>)\n');
return [...stacks].map(([_, stack]) => stack);
}
/**
* May be invoked from outside the VM.
*/
getAndResetSinkCalls(): SinkCall[] {
const { sinkCalls } = this;
this.sinkCalls = [];
return sinkCalls;
}
/**
* Buffer a Workflow command to be collected at the end of the current activation.
*
* Prevents commands from being added after Workflow completion.
*/
pushCommand(cmd: coresdk.workflow_commands.IWorkflowCommand, complete = false): void {
this.commands.push(cmd);
if (complete) {
this.completed = true;
}
}
concludeActivation(): ActivationCompletion {
return {
commands: this.commands.splice(0),
usedInternalFlags: [...this.knownFlags],
versioningBehavior: this.versioningBehavior,
};
}
public async startWorkflowNextHandler({ args }: WorkflowExecuteInput): Promise<any> {
const { workflow } = this;
if (workflow == null) {
throw new IllegalStateError('Workflow uninitialized');
}
return await workflow(...args);
}
public startWorkflow(activation: coresdk.workflow_activation.IInitializeWorkflow): void {
const execute = composeInterceptors(this.interceptors.inbound, 'execute', this.startWorkflowNextHandler.bind(this));
const context = this.workflowSerializationContext();
untrackPromise(
executeWithLifecycleLogging(() =>
execute({
headers: activation.headers ?? {},
args: arrayFromPayloads(this.payloadConverter, activation.arguments, context),
})
).then(this.completeWorkflow.bind(this), this.handleWorkflowFailure.bind(this))
);
}
public initializeWorkflow(activation: coresdk.workflow_activation.IInitializeWorkflow): void {
const { continuedFailure, lastCompletionResult, memo, searchAttributes } = activation;
const context = this.workflowSerializationContext();
// Most things related to initialization have already been handled in the constructor
this.mutateWorkflowInfo((info) => ({
...info,
searchAttributes: decodeSearchAttributes(searchAttributes?.indexedFields),
typedSearchAttributes: decodeTypedSearchAttributes(searchAttributes?.indexedFields),
memo: mapFromPayloads(this.payloadConverter, memo?.fields, context),
lastResult: fromPayloadsAtIndex(this.payloadConverter, 0, lastCompletionResult?.payloads, context),
lastFailure:
continuedFailure != null
? this.failureConverter.failureToError(continuedFailure, this.payloadConverter, context)
: undefined,
}));
const workflowDefinitionOpts = this.workflowDefinitionOptionsGetter?.();
if (workflowDefinitionOpts) {
this.versioningBehavior = workflowDefinitionOpts.versioningBehavior;
this.workflowDefinitionFailureExceptionTypes = workflowDefinitionOpts.failureExceptionTypes;
}
}
public cancelWorkflow(_activation: coresdk.workflow_activation.ICancelWorkflow): void {
this.cancelled = true;
this.rootScope.cancel();
}
public fireTimer(activation: coresdk.workflow_activation.IFireTimer): void {
// Timers are a special case where their completion might not be in Workflow state,
// this is due to immediate timer cancellation that doesn't go wait for Core.
const completion = this.maybeConsumeCompletion('timer', getSeq(activation));
completion?.resolve(undefined);
}
public resolveActivity(activation: coresdk.workflow_activation.IResolveActivity): void {
if (!activation.result) {
throw new TypeError('Got ResolveActivity activation with no result');
}
const { resolve, reject, context } = this.consumeCompletion('activity', getSeq(activation));
if (activation.result.completed) {
const completed = activation.result.completed;
const result = completed.result ? this.payloadConverter.fromPayload(completed.result, context) : undefined;
resolve(result);
} else if (activation.result.failed) {
const { failure } = activation.result.failed;
if (failure == null) {
throw new TypeError('Got failed result with no failure attribute');
}
reject(this.failureConverter.failureToError(failure, this.payloadConverter, context));
} else if (activation.result.cancelled) {
const { failure } = activation.result.cancelled;
if (failure == null) {
throw new TypeError('Got cancelled result with no failure attribute');
}
reject(this.failureConverter.failureToError(failure, this.payloadConverter, context));
} else if (activation.result.backoff) {
reject(new LocalActivityDoBackoff(activation.result.backoff));
}
}
public resolveChildWorkflowExecutionStart(
activation: coresdk.workflow_activation.IResolveChildWorkflowExecutionStart
): void {
const { resolve, reject, context } = this.consumeCompletion('childWorkflowStart', getSeq(activation));
if (activation.succeeded) {
if (!activation.succeeded.runId) {
throw new TypeError('Got ResolveChildWorkflowExecutionStart with no runId');
}
resolve(activation.succeeded.runId);
} else if (activation.failed) {
if (decodeStartChildWorkflowExecutionFailedCause(activation.failed.cause) !== 'WORKFLOW_ALREADY_EXISTS') {
throw new IllegalStateError('Got unknown StartChildWorkflowExecutionFailedCause');
}
if (!(activation.seq && activation.failed.workflowId && activation.failed.workflowType)) {
throw new TypeError('Missing attributes in activation job');
}
reject(
new WorkflowExecutionAlreadyStartedError(
'Workflow execution already started',
activation.failed.workflowId,
activation.failed.workflowType
)
);
} else if (activation.cancelled) {
if (!activation.cancelled.failure) {
throw new TypeError('Got no failure in cancelled variant');
}
reject(this.failureConverter.failureToError(activation.cancelled.failure, this.payloadConverter, context));
} else {
throw new TypeError('Got ResolveChildWorkflowExecutionStart with no status');
}
}
public resolveChildWorkflowExecution(activation: coresdk.workflow_activation.IResolveChildWorkflowExecution): void {
if (!activation.result) {
throw new TypeError('Got ResolveChildWorkflowExecution activation with no result');
}
const { resolve, reject, context } = this.consumeCompletion('childWorkflowComplete', getSeq(activation));
if (activation.result.completed) {
const completed = activation.result.completed;
const result = completed.result ? this.payloadConverter.fromPayload(completed.result, context) : undefined;
resolve(result);
} else if (activation.result.failed) {
const { failure } = activation.result.failed;
if (failure == null) {
throw new TypeError('Got failed result with no failure attribute');
}
reject(this.failureConverter.failureToError(failure, this.payloadConverter, context));
} else if (activation.result.cancelled) {
const { failure } = activation.result.cancelled;
if (failure == null) {
throw new TypeError('Got cancelled result with no failure attribute');
}
reject(this.failureConverter.failureToError(failure, this.payloadConverter, context));
}
}
public resolveNexusOperationStart(activation: coresdk.workflow_activation.IResolveNexusOperationStart): void {
const seq = getSeq(activation);
const { resolve, reject } = this.consumeCompletion('nexusOperationStart', seq);
if (!activation.failed) {
const completePromise = new Promise((resolve, reject) => {
this.completions.nexusOperationComplete.set(seq, {
resolve,
reject,
});
});
untrackPromise(completePromise);
untrackPromise(completePromise.catch(() => undefined));
resolve({ token: activation.operationToken!, result: completePromise });
} else {
reject(this.failureToError(activation.failed));
}
}
public resolveNexusOperation(activation: coresdk.workflow_activation.IResolveNexusOperation): void {
const seq = getSeq(activation);
const context = this.workflowSerializationContext();
if (activation.result?.completed) {
const result = this.payloadConverter.fromPayload(activation.result.completed, context);
// It is possible for ResolveNexusOperation to be received without a prior ResolveNexusOperationStart,
// e.g. because the handler completed the Operation synchronously.
const startCompletion = this.maybeConsumeCompletion('nexusOperationStart', seq);
if (startCompletion) {
startCompletion.resolve({ result: Promise.resolve(result) });
} else {
this.consumeCompletion('nexusOperationComplete', seq).resolve(result);
}
} else {
let err: Error;
if (activation.result?.failed) {
err = this.failureToError(activation.result.failed);
} else if (activation.result?.cancelled) {
err = this.failureToError(activation.result.cancelled);
} else if (activation.result?.timedOut) {
err = this.failureToError(activation.result.timedOut);
}
const completion =
this.maybeConsumeCompletion('nexusOperationStart', seq) ??
this.consumeCompletion('nexusOperationComplete', seq);
completion.reject(err!);
}
}
// Intentionally non-async function so this handler doesn't show up in the stack trace
protected queryWorkflowNextHandler({ queryName, args }: QueryInput): Promise<unknown> {
let fn = this.queryHandlers.get(queryName)?.handler;
if (fn === undefined && this.defaultQueryHandler !== undefined) {
fn = this.defaultQueryHandler.bind(undefined, queryName);
}
// No handler or default registered, fail.
if (fn === undefined) {
const knownQueryTypes = [...this.queryHandlers.keys()].join(' ');
// Fail the query
return Promise.reject(
new ReferenceError(
`Workflow did not register a handler for ${queryName}. Registered queries: [${knownQueryTypes}]`
)
);
}
// Execute handler.
try {
const ret = fn(...args);
if (ret instanceof Promise) {
return Promise.reject(new DeterminismViolationError('Query handlers should not return a Promise'));
}
return Promise.resolve(ret);
} catch (err) {
return Promise.reject(err);
}
}
public queryWorkflow(activation: coresdk.workflow_activation.IQueryWorkflow): void {
const { queryType, queryId, headers } = activation;
if (!(queryType && queryId)) {
throw new TypeError('Missing query activation attributes');
}
// Reject __temporal_-prefixed queries that would otherwise be routed to the
// user's default handler. A specific registered handler (e.g. from a
// contrib package) is allowed through.
if (
queryType.startsWith(TEMPORAL_RESERVED_PREFIX) &&
!this.queryHandlers.has(queryType) &&
this.defaultQueryHandler !== undefined
) {
throw new TypeError(`Cannot use query name: '${queryType}', with reserved prefix: '${TEMPORAL_RESERVED_PREFIX}'`);
}
// Skip interceptors if it's an internal query.
const isInternalQuery =
queryType.startsWith(TEMPORAL_RESERVED_PREFIX) ||
queryType === STACK_TRACE_QUERY_NAME ||
queryType === ENHANCED_STACK_TRACE_QUERY_NAME;
const interceptors = isInternalQuery ? [] : this.interceptors.inbound;
const execute = composeInterceptors(interceptors, 'handleQuery', this.queryWorkflowNextHandler.bind(this));
const context = this.workflowSerializationContext();
execute({
queryName: queryType,
args: arrayFromPayloads(this.payloadConverter, activation.arguments, context),
queryId,
headers: headers ?? {},
}).then(
(result) => this.completeQuery(queryId, result),
(reason) => this.failQuery(queryId, reason)
);
}
public doUpdate(activation: coresdk.workflow_activation.IDoUpdate): void {
const { id: updateId, protocolInstanceId, name, headers, runValidator } = activation;
if (!updateId) {
throw new TypeError('Missing activation update id');
}
if (!name) {
throw new TypeError('Missing activation update name');
}
if (!protocolInstanceId) {
throw new TypeError('Missing activation update protocolInstanceId');
}
// Reject __temporal_-prefixed updates that would otherwise be routed to the
// user's default handler. A specific registered handler (e.g. from a
// contrib package) is allowed through, and unregistered names without a
// default handler fall through to the buffer-then-reject path below.
if (
name.startsWith(TEMPORAL_RESERVED_PREFIX) &&
!this.updateHandlers.has(name) &&
this.defaultUpdateHandler !== undefined
) {
throw new TypeError(`Cannot use update name: '${name}', with reserved prefix: '${TEMPORAL_RESERVED_PREFIX}'`);
}
// Skip interceptors if it's an internal update.
const isInternalUpdate =
name.startsWith(TEMPORAL_RESERVED_PREFIX) ||
name === STACK_TRACE_QUERY_NAME ||
name === ENHANCED_STACK_TRACE_QUERY_NAME;
const interceptors = isInternalUpdate ? [] : this.interceptors.inbound;
const entry =
this.updateHandlers.get(name) ??
(this.defaultUpdateHandler
? {
handler: this.defaultUpdateHandler.bind(undefined, name),
validator: undefined,
// Default to a warning policy.
unfinishedPolicy: HandlerUnfinishedPolicy.WARN_AND_ABANDON,
}
: null);
// If we don't have an entry from either source, buffer and return
if (entry == null) {
this.bufferedUpdates.push(activation);
return;
}
const makeInput = (): UpdateInput => {
const context = this.workflowSerializationContext();
return {
updateId,
args: arrayFromPayloads(this.payloadConverter, activation.input, context),
name,
headers: headers ?? {},
};
};
// The implementation below is responsible for upholding, and constrained
// by, the following contract:
//
// 1. If no validator is present then validation interceptors will not be run.
//
// 2. During validation, any error must fail the Update; during the Update
// itself, Temporal errors fail the Update whereas other errors fail the
// activation.
//
// 3. The handler must not see any mutations of the arguments made by the
// validator.
//
// 4. Any error when decoding/deserializing input must be caught and result
// in rejection of the Update before it is accepted, even if there is no
// validator.
//
// 5. The initial synchronous portion of the (async) Update handler should
// be executed after the (sync) validator completes such that there is
// minimal opportunity for a different concurrent task to be scheduled
// between them.
//
// 6. The stack trace view provided in the Temporal UI must not be polluted
// by promises that do not derive from user code. This implies that
// async/await syntax may not be used.
//
// Note that there is a deliberately unhandled promise rejection below.
// These are caught elsewhere and fail the corresponding activation.
const doUpdateImpl = async () => {
let input: UpdateInput;
try {
if (runValidator && entry.validator) {
// Temporarily mark as not replaying history events during validator execution
// so that logging is permitted. Validators are live read-only operations.
const wasReplayingHistoryEvents = this.info.unsafe.isReplayingHistoryEvents;
this.mutateWorkflowInfo((info) => ({
...info,
unsafe: { ...info.unsafe, isReplayingHistoryEvents: false },
}));
try {
const validate = composeInterceptors(
interceptors,
'validateUpdate',
this.validateUpdateNextHandler.bind(this, entry.validator)
);
validate(makeInput());
} finally {
this.mutateWorkflowInfo((info) => ({
...info,
unsafe: { ...info.unsafe, isReplayingHistoryEvents: wasReplayingHistoryEvents },
}));
}
}
input = makeInput();
} catch (error) {
this.rejectUpdate(protocolInstanceId, error);
return;
}
this.acceptUpdate(protocolInstanceId);
const execute = composeInterceptors(
interceptors,
'handleUpdate',
this.updateNextHandler.bind(this, entry.handler)
);
const { unfinishedPolicy } = entry;
this.inProgressUpdates.set(updateId, { name, unfinishedPolicy, id: updateId });
const res = execute(input)
.then((result) => this.completeUpdate(protocolInstanceId, result))
.catch((error) => {
if (error instanceof TemporalFailure) {
this.rejectUpdate(protocolInstanceId, error);
} else {
this.handleWorkflowFailure(error);
}
})
.finally(() => this.inProgressUpdates.delete(updateId));
untrackPromise(res);
return res;
};
untrackPromise(UpdateScope.updateWithInfo(updateId, name, doUpdateImpl));
}
protected async updateNextHandler(handler: WorkflowUpdateType, { args }: UpdateInput): Promise<unknown> {
return await handler(...args);
}
protected validateUpdateNextHandler(validator: WorkflowUpdateValidatorType | undefined, { args }: UpdateInput): void {
if (validator) {
validator(...args);
}
}
public dispatchBufferedUpdates(): void {
const bufferedUpdates = this.bufferedUpdates;
while (bufferedUpdates.length) {
// We have a default update handler, so all updates are dispatchable.
if (this.defaultUpdateHandler) {
const update = bufferedUpdates.shift();
// Logically, this must be defined as we're in the loop.
// But Typescript doesn't know that so we use a non-null assertion (!).
this.doUpdate(update!);
} else {
const foundIndex = bufferedUpdates.findIndex((update) => this.updateHandlers.has(update.name as string));
if (foundIndex === -1) {
// No buffered Updates have a handler yet.
break;
}
const [update] = bufferedUpdates.splice(foundIndex, 1);
this.doUpdate(update!);
}
}
}
public rejectBufferedUpdates(): void {
while (this.bufferedUpdates.length) {
const update = this.bufferedUpdates.shift();
if (update) {
this.rejectUpdate(
update.protocolInstanceId!,
ApplicationFailure.nonRetryable(`No registered handler for update: ${update.name}`)
);
}
}
}
public async signalWorkflowNextHandler({ signalName, args }: SignalInput): Promise<void> {
const fn = this.signalHandlers.get(signalName)?.handler;
if (fn) {
return await fn(...args);
} else if (this.defaultSignalHandler) {
return await this.defaultSignalHandler(signalName, ...args);
} else {
throw new IllegalStateError(`No registered signal handler for signal: ${signalName}`);
}
}
public signalWorkflow(activation: coresdk.workflow_activation.ISignalWorkflow): void {
const { signalName, headers } = activation;
if (!signalName) {
throw new TypeError('Missing activation signalName');
}
// Reject __temporal_-prefixed signals that would otherwise be routed to the
// user's default handler. A specific registered handler (e.g. from a
// contrib package) is allowed through, and unregistered names without a
// default handler fall through to the buffer-then-reject path below.
if (
signalName.startsWith(TEMPORAL_RESERVED_PREFIX) &&
!this.signalHandlers.has(signalName) &&
this.defaultSignalHandler !== undefined
) {
throw new TypeError(
`Cannot use signal name: '${signalName}', with reserved prefix: '${TEMPORAL_RESERVED_PREFIX}'`
);
}
// Skip interceptors if it's an internal signal.
const isInternalSignal =
signalName.startsWith(TEMPORAL_RESERVED_PREFIX) ||
signalName === STACK_TRACE_QUERY_NAME ||
signalName === ENHANCED_STACK_TRACE_QUERY_NAME;
const interceptors = isInternalSignal ? [] : this.interceptors.inbound;
if (!this.signalHandlers.has(signalName) && !this.defaultSignalHandler) {
this.bufferedSignals.push(activation);
return;
}
// If we fall through to the default signal handler then the unfinished
// policy is WARN_AND_ABANDON; users currently have no way to silence any
// ensuing warnings.
const unfinishedPolicy =
this.signalHandlers.get(signalName)?.unfinishedPolicy ?? HandlerUnfinishedPolicy.WARN_AND_ABANDON;
const signalExecutionNum = this.signalHandlerExecutionSeq++;
this.inProgressSignals.set(signalExecutionNum, { name: signalName, unfinishedPolicy });
const execute = composeInterceptors(interceptors, 'handleSignal', this.signalWorkflowNextHandler.bind(this));
const context = this.workflowSerializationContext();
execute({
args: arrayFromPayloads(this.payloadConverter, activation.input, context),
signalName,
headers: headers ?? {},
})
.catch(this.handleWorkflowFailure.bind(this))
.finally(() => this.inProgressSignals.delete(signalExecutionNum));
}
public dispatchBufferedSignals(): void {
const bufferedSignals = this.bufferedSignals;
while (bufferedSignals.length) {
if (this.defaultSignalHandler) {
// We have a default signal handler, so all signals are dispatchable
this.signalWorkflow(bufferedSignals.shift()!);
} else {
const foundIndex = bufferedSignals.findIndex((signal) => this.signalHandlers.has(signal.signalName as string));
if (foundIndex === -1) break;
const [signal] = bufferedSignals.splice(foundIndex, 1);
this.signalWorkflow(signal!);
}
}
}
public resolveSignalExternalWorkflow(activation: coresdk.workflow_activation.IResolveSignalExternalWorkflow): void {
const { resolve, reject, context } = this.consumeCompletion('signalWorkflow', getSeq(activation));
if (activation.failure) {
reject(this.failureConverter.failureToError(activation.failure, this.payloadConverter, context));
} else {
resolve(undefined);
}
}
public resolveRequestCancelExternalWorkflow(
activation: coresdk.workflow_activation.IResolveRequestCancelExternalWorkflow
): void {
const { resolve, reject, context } = this.consumeCompletion('cancelWorkflow', getSeq(activation));
if (activation.failure) {
reject(this.failureConverter.failureToError(activation.failure, this.payloadConverter, context));
} else {
resolve(undefined);
}
}
public warnIfUnfinishedHandlers(): void {
if (this.workflowTaskError) return;
const getWarnable = (handlerExecutions: Iterable<MessageHandlerExecution>): MessageHandlerExecution[] => {
return Array.from(handlerExecutions).filter(
(ex) => ex.unfinishedPolicy === HandlerUnfinishedPolicy.WARN_AND_ABANDON
);
};
const warnableUpdates = getWarnable(this.inProgressUpdates.values());
if (warnableUpdates.length > 0) {
log.warn(makeUnfinishedUpdateHandlerMessage(warnableUpdates));
}
const warnableSignals = getWarnable(this.inProgressSignals.values());
if (warnableSignals.length > 0) {
log.warn(makeUnfinishedSignalHandlerMessage(warnableSignals));
}
}
public updateRandomSeed(activation: coresdk.workflow_activation.IUpdateRandomSeed): void {
if (!activation.randomnessSeed) {
throw new TypeError('Expected activation with randomnessSeed attribute');
}
this.setRandomnessSeed(activation.randomnessSeed.toBytes());
}
public notifyHasPatch(activation: coresdk.workflow_activation.INotifyHasPatch): void {
if (!this.info.unsafe.isReplaying)
throw new IllegalStateError('Unexpected notifyHasPatch job on non-replay activation');
if (!activation.patchId) throw new TypeError('notifyHasPatch missing patch id');
this.knownPresentPatches.add(activation.patchId);
}
public patchInternal(patchId: string, deprecated: boolean): boolean {
if (this.workflow === undefined) {
throw new IllegalStateError('Patches cannot be used before Workflow starts');
}
const usePatch = !this.info.unsafe.isReplaying || this.knownPresentPatches.has(patchId);
// Avoid sending commands for patches core already knows about.
// This optimization enables development of automatic patching tools.
if (usePatch && !this.sentPatches.has(patchId)) {
this.pushCommand({
setPatchMarker: { patchId, deprecated },
});
this.sentPatches.add(patchId);
}
return usePatch;
}
/**
* Called early while handling an activation to register known flags.
* May be invoked from outside the VM.
*/
public addKnownFlags(flags: number[]): void {
for (const flag of flags) {
assertValidFlag(flag);
this.knownFlags.add(flag);
}
}
/**
* Check if an SDK Flag may be considered as enabled for the current Workflow Task.
*
* SDK flags play a role similar to the `patched()` API, but are meant for internal usage by the
* SDK itself. They make it possible for the SDK to evolve its behaviors over time, while still
* maintaining compatibility with Workflow histories produced by older SDKs, without causing
* determinism violations.
*
* May be invoked from outside the VM.
*/
public hasFlag(flag: SdkFlag): boolean {
if (this.knownFlags.has(flag.id)) return true;
// If not replaying, enable the flag if it is configured to be enabled by default. Setting a
// flag's default to false allows progressive rollout of new feature flags, with the possibility
// of reverting back to a version of the SDK where the flag is supported but disabled by default.
// It is also useful for testing purpose.
if (!this.info.unsafe.isReplaying && flag.default) {
this.knownFlags.add(flag.id);
return true;
}
// When replaying, a flag is considered enabled if it was enabled during the original execution of
// that Workflow Task; this is normally determined by the presence of the flag ID in the corresponding
// WFT Completed's `sdkMetadata.langUsedFlags`.
//
// SDK Flag Alternate Condition provides an alternative way of determining whether a flag should
// be considered as enabled for the current WFT; e.g. by looking at the version of the SDK that
// emitted a WFT. The main use case for this is to retroactively turn on some flags for WFT emitted
// by previous SDKs that contained a bug. Alt Conditions should only be used as a last resort.
//
// Note that conditions are only evaluated while replaying. Also, alternate conditions will not
// cause the flag to be persisted to the "used flags" set, which means that further Workflow Tasks
// may not reflect this flag if the condition no longer holds. This is so to avoid incorrect
// behaviors in case where a Workflow Execution has gone through a newer SDK version then again
// through an older one.
if (this.info.unsafe.isReplaying && flag.alternativeConditions) {
for (const cond of flag.alternativeConditions) {
if (cond({ info: this.info, sdkVersion: this.sdkVersion })) return true;
}
}
return false;
}
public removeFromCache(): void {
throw new IllegalStateError('removeFromCache activation job should not reach workflow');
}
/**
* Transforms failures into a command to be sent to the server.
* Used to handle any failure emitted by the Workflow.
*/
handleWorkflowFailure(error: unknown): void {
if (this.cancelled && isCancellation(error)) {
this.pushCommand({ cancelWorkflowExecution: {} }, true);
} else if (error instanceof ContinueAsNew) {
this.pushCommand({ continueAsNewWorkflowExecution: error.command }, true);
} else if (error instanceof TemporalFailure || this.isConfiguredFailureException(error)) {
// Fail the workflow. We do not want to issue unfinishedHandlers warnings. To achieve that, we
// mark all handlers as completed now.
this.inProgressSignals.clear();
this.inProgressUpdates.clear();
this.pushCommand(
{
failWorkflowExecution: {
failure: this.errorToFailure(ensureTemporalFailure(error)),
},
},
true
);
} else {
this.recordWorkflowTaskError(error);
}
}
/**
* Returns true if the given error matches any of the configured failure exception types
* (from {@link WorkerOptions.workflowFailureErrorTypes} or
* {@link WorkflowDefinitionOptions.failureExceptionTypes}).
*/
private isConfiguredFailureException(error: unknown): boolean {
// Check class references from WorkflowDefinitionOptions (instanceof-based, supports subclasses)
if (this.workflowDefinitionFailureExceptionTypes) {
// We guarantee that including Error in the list will catch _any_ error.
if (this.workflowDefinitionFailureExceptionTypes.includes(Error)) return true;
for (const errorType of this.workflowDefinitionFailureExceptionTypes) {
if (error instanceof errorType) return true;
}
}
// Check class name strings from WorkerOptions (prototype-chain-based)
if (this.failureExceptionTypeNames.length > 0) {
// We guarantee that including 'Error' in the list will catch _any_ error.
if (this.failureExceptionTypeNames.includes('Error')) return true;
if (typeof error === 'object' && error !== null) {
let ctor = (error as any).constructor;
while (ctor != null && ctor !== Function.prototype)