@temporalio/worker
Version:
Temporal.io SDK Worker sub-package
1,256 lines (1,176 loc) • 77.4 kB
text/typescript
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import * as path from 'node:path';
import * as vm from 'node:vm';
import { EventEmitter, on } from 'node:events';
import { setTimeout as setTimeoutCallback } from 'node:timers';
import {
BehaviorSubject,
EMPTY,
from,
lastValueFrom,
merge,
MonoTypeOperatorFunction,
Observable,
OperatorFunction,
pipe,
race,
Subject,
} from 'rxjs';
import { delay, filter, first, ignoreElements, last, map, mergeMap, takeUntil, takeWhile, tap } from 'rxjs/operators';
import type { RawSourceMap } from 'source-map';
import { Info as ActivityInfo } from '@temporalio/activity';
import {
DataConverter,
decompileRetryPolicy,
defaultPayloadConverter,
IllegalStateError,
LoadedDataConverter,
SdkComponent,
Payload,
ApplicationFailure,
ensureApplicationFailure,
TypedSearchAttributes,
decodePriority,
MetricMeter,
} from '@temporalio/common';
import {
decodeArrayFromPayloads,
Decoded,
decodeFromPayloadsAtIndex,
encodeErrorToFailure,
encodeToPayload,
} from '@temporalio/common/lib/internal-non-workflow';
import { historyFromJSON } from '@temporalio/common/lib/proto-utils';
import {
Duration,
msToNumber,
optionalTsToDate,
optionalTsToMs,
requiredTsToMs,
tsToDate,
tsToMs,
} from '@temporalio/common/lib/time';
import { LoggerWithComposedMetadata } from '@temporalio/common/lib/logger';
import { errorMessage, NonNullableObject, OmitFirstParam } from '@temporalio/common/lib/type-helpers';
import { workflowLogAttributes } from '@temporalio/workflow/lib/logs';
import { native } from '@temporalio/core-bridge';
import { coresdk, temporal } from '@temporalio/proto';
import { type SinkCall, type WorkflowInfo } from '@temporalio/workflow';
import { Activity, CancelReason, activityLogAttributes } from './activity';
import { extractNativeClient, extractReferenceHolders, InternalNativeConnection, NativeConnection } from './connection';
import { ActivityExecuteInput } from './interceptors';
import { Logger } from './logger';
import pkg from './pkg';
import {
EvictionReason,
evictionReasonToReplayError,
RemoveFromCache,
ReplayHistoriesIterable,
ReplayResult,
} from './replay';
import { History, Runtime } from './runtime';
import { CloseableGroupedObservable, closeableGroupBy, mapWithState, mergeMapWithState } from './rxutils';
import {
byteArrayToBuffer,
convertDeploymentVersion,
convertToParentWorkflowType,
convertToRootWorkflowType,
} from './utils';
import {
CompiledWorkerOptions,
CompiledWorkerOptionsWithBuildId,
compileWorkerOptions,
isCodeBundleOption,
isPathBundleOption,
ReplayWorkerOptions,
toNativeWorkerOptions,
WorkerOptions,
WorkflowBundle,
} from './worker-options';
import { WorkflowCodecRunner } from './workflow-codec-runner';
import { defaultWorkflowInterceptorModules, WorkflowCodeBundler } from './workflow/bundler';
import { Workflow, WorkflowCreator } from './workflow/interface';
import { ReusableVMWorkflowCreator } from './workflow/reusable-vm';
import { ThreadedVMWorkflowCreator } from './workflow/threaded-vm';
import { VMWorkflowCreator } from './workflow/vm';
import { WorkflowBundleWithSourceMapAndFilename } from './workflow/workflow-worker-thread/input';
import {
CombinedWorkerRunError,
GracefulShutdownPeriodExpiredError,
PromiseCompletionTimeoutError,
ShutdownError,
UnexpectedError,
} from './errors';
export { DataConverter, defaultPayloadConverter };
/**
* The worker's possible states
* * `INITIALIZED` - The initial state of the Worker after calling {@link Worker.create} and successful connection to the server
* * `RUNNING` - {@link Worker.run} was called, polling task queues
* * `STOPPING` - {@link Worker.shutdown} was called or received shutdown signal, worker will forcefully shutdown in {@link WorkerOptions.shutdownGraceTime | shutdownGraceTime}
* * `DRAINING` - Core has indicated that shutdown is complete and all Workflow tasks have been drained, waiting for activities and cached workflows eviction
* * `DRAINED` - All activities and workflows have completed, ready to shutdown
* * `STOPPED` - Shutdown complete, {@link Worker.run} resolves
* * `FAILED` - Worker encountered an unrecoverable error, {@link Worker.run} should reject with the error
*/
export type State = 'INITIALIZED' | 'RUNNING' | 'STOPPED' | 'STOPPING' | 'DRAINING' | 'DRAINED' | 'FAILED';
type WorkflowActivation = coresdk.workflow_activation.WorkflowActivation;
export type ActivityTaskWithBase64Token = {
task: coresdk.activity_task.ActivityTask;
base64TaskToken: string;
// The unaltered protobuf-encoded ActivityTask; kept so that it can be printed
// out for analysis if decoding fails at a later step.
protobufEncodedTask: Buffer;
};
interface EvictionWithRunID {
runId: string;
evictJob: coresdk.workflow_activation.IRemoveFromCache;
}
export interface NativeWorkerLike {
type: 'worker';
initiateShutdown: OmitFirstParam<typeof native.workerInitiateShutdown>;
finalizeShutdown(): Promise<void>;
flushCoreLogs(): void;
pollWorkflowActivation: OmitFirstParam<typeof native.workerPollWorkflowActivation>;
pollActivityTask: OmitFirstParam<typeof native.workerPollActivityTask>;
completeWorkflowActivation: OmitFirstParam<typeof native.workerCompleteWorkflowActivation>;
completeActivityTask: OmitFirstParam<typeof native.workerCompleteActivityTask>;
recordActivityHeartbeat: OmitFirstParam<typeof native.workerRecordActivityHeartbeat>;
}
export interface NativeReplayHandle {
worker: NativeWorkerLike;
historyPusher: native.HistoryPusher;
}
interface NativeWorkerConstructor {
create(
runtime: Runtime,
connection: NativeConnection,
options: CompiledWorkerOptionsWithBuildId
): Promise<NativeWorkerLike>;
createReplay(runtime: Runtime, options: CompiledWorkerOptionsWithBuildId): Promise<NativeReplayHandle>;
}
interface WorkflowWithLogAttributes {
workflow: Workflow;
logAttributes: Record<string, unknown>;
}
function addBuildIdIfMissing(options: CompiledWorkerOptions, bundleCode?: string): CompiledWorkerOptionsWithBuildId {
const bid = options.buildId; // eslint-disable-line deprecation/deprecation
if (bid != null) {
return options as CompiledWorkerOptionsWithBuildId;
}
const suffix = bundleCode ? `+${crypto.createHash('sha256').update(bundleCode).digest('hex')}` : '';
return { ...options, buildId: `${pkg.name}@${pkg.version}${suffix}` };
}
export class NativeWorker implements NativeWorkerLike {
public readonly type = 'worker';
public readonly pollWorkflowActivation: OmitFirstParam<typeof native.workerPollWorkflowActivation>;
public readonly pollActivityTask: OmitFirstParam<typeof native.workerPollActivityTask>;
public readonly completeWorkflowActivation: OmitFirstParam<typeof native.workerCompleteWorkflowActivation>;
public readonly completeActivityTask: OmitFirstParam<typeof native.workerCompleteActivityTask>;
public readonly recordActivityHeartbeat: OmitFirstParam<typeof native.workerRecordActivityHeartbeat>;
public readonly initiateShutdown: OmitFirstParam<typeof native.workerInitiateShutdown>;
public static async create(
runtime: Runtime,
connection: NativeConnection,
options: CompiledWorkerOptionsWithBuildId
): Promise<NativeWorkerLike> {
const nativeWorker = await runtime.registerWorker(extractNativeClient(connection), toNativeWorkerOptions(options));
return new NativeWorker(runtime, nativeWorker);
}
public static async createReplay(
runtime: Runtime,
options: CompiledWorkerOptionsWithBuildId
): Promise<NativeReplayHandle> {
const [worker, historyPusher] = await runtime.createReplayWorker(toNativeWorkerOptions(options));
return {
worker: new NativeWorker(runtime, worker),
historyPusher,
};
}
protected constructor(
protected readonly runtime: Runtime,
protected readonly nativeWorker: native.Worker
) {
this.pollWorkflowActivation = native.workerPollWorkflowActivation.bind(undefined, nativeWorker);
this.pollActivityTask = native.workerPollActivityTask.bind(undefined, nativeWorker);
this.completeWorkflowActivation = native.workerCompleteWorkflowActivation.bind(undefined, nativeWorker);
this.completeActivityTask = native.workerCompleteActivityTask.bind(undefined, nativeWorker);
this.recordActivityHeartbeat = native.workerRecordActivityHeartbeat.bind(undefined, nativeWorker);
this.initiateShutdown = native.workerInitiateShutdown.bind(undefined, nativeWorker);
}
flushCoreLogs(): void {
this.runtime.flushLogs();
}
public async finalizeShutdown(): Promise<void> {
await this.runtime.deregisterWorker(this.nativeWorker);
}
}
function formatTaskToken(taskToken: Uint8Array) {
return Buffer.from(taskToken).toString('base64');
}
/**
* Notify that an activity has started, used as input to {@link Worker.activityHeartbeatSubject}
*
* Used to detect rogue activities.
*/
interface HeartbeatCreateNotification {
type: 'create';
base64TaskToken: string;
}
/**
* Heartbeat request used as input to {@link Worker.activityHeartbeatSubject}
*/
interface Heartbeat {
type: 'heartbeat';
info: ActivityInfo;
base64TaskToken: string;
taskToken: Uint8Array;
details?: any;
onError: () => void;
}
/**
* Notifies that an activity has been complete, used as input to {@link Worker.activityHeartbeatSubject}
*/
interface ActivityCompleteNotification {
type: 'completion';
flushRequired: boolean;
callback(): void;
base64TaskToken: string;
}
/**
* Notifies that an Activity heartbeat has been flushed, used as input to {@link Worker.activityHeartbeatSubject}
*/
interface HeartbeatFlushNotification {
type: 'flush';
base64TaskToken: string;
}
/**
* Input for the {@link Worker.activityHeartbeatSubject}
*/
type HeartbeatInput =
| Heartbeat
| ActivityCompleteNotification
| HeartbeatFlushNotification
| HeartbeatCreateNotification;
/**
* State for managing a single Activity's heartbeat sending
*/
interface HeartbeatState {
closed: boolean;
processing: boolean;
completionCallback?: () => void;
pending?: Heartbeat;
}
/**
* Request to send a heartbeat, used as output from the Activity heartbeat state mapper
*/
interface HeartbeatSendRequest {
type: 'send';
heartbeat: Heartbeat;
}
/**
* Request to close an Activity heartbeat stream, used as output from the Activity heartbeat state mapper
*/
interface HeartbeatGroupCloseRequest {
type: 'close';
completionCallback?: () => void;
}
/**
* Output from the Activity heartbeat state mapper
*/
type HeartbeatOutput = HeartbeatSendRequest | HeartbeatGroupCloseRequest;
/**
* Used as the return type of the Activity heartbeat state mapper
*/
interface HeartbeatStateAndOutput {
state: HeartbeatState;
output: HeartbeatOutput | null;
}
export type PollerState = 'POLLING' | 'SHUTDOWN' | 'FAILED';
/**
* Status overview of a Worker.
* Useful for troubleshooting issues and overall obeservability.
*/
export interface WorkerStatus {
/**
* General run state of a Worker
*/
runState: State;
/**
* General state of the Workflow poller
*/
workflowPollerState: PollerState;
/**
* General state of the Activity poller
*/
activityPollerState: PollerState;
/**
* Whether this Worker has an outstanding Workflow poll request
*/
hasOutstandingWorkflowPoll: boolean;
/**
* Whether this Worker has an outstanding Activity poll request
*/
hasOutstandingActivityPoll: boolean;
/**
* Number of in-flight (currently actively processed) Workflow activations
*/
numInFlightWorkflowActivations: number;
/**
* Number of in-flight (currently actively processed) Activities
*
* This includes both local and non-local Activities.
*
* See {@link numInFlightNonLocalActivities} and {@link numInFlightLocalActivities} for a breakdown.
*/
numInFlightActivities: number;
/**
* Number of in-flight (currently actively processed) non-Local Activities
*/
numInFlightNonLocalActivities: number;
/**
* Number of in-flight (currently actively processed) Local Activities
*/
numInFlightLocalActivities: number;
/**
* Number of Workflow executions cached in Worker memory
*/
numCachedWorkflows: number;
/**
* Number of running Activities that have emitted a heartbeat
*/
numHeartbeatingActivities: number;
}
interface RunUntilOptions {
/**
* Maximum time to wait for the provided Promise to complete after the Worker has stopped or failed.
*
* Until TS SDK 1.11.2, `Worker.runUntil(...)` would wait _indefinitely_ for both the Worker's run
* Promise _and_ the provided Promise to resolve or fail, _even in error cases_. In most practical
* use cases, that would create a possibility for the Worker to hang indefinitely if the Worker
* was stopped due to "unexpected" factors
*
* For example, in the common test idiom show below, sending a `SIGINT` to the process would
* initiate shutdown of the Worker, potentially resulting in termination of the Worker before the
* Workflow completes; in that case, the Workflow would never complete, and consequently, the
* `runUntil` Promise would never resolve, leaving the process in a hang state.
*
* ```ts
* await Worker.runUntil(() => client.workflow.execute(...));
* ```
*
* The behavior of `Worker.runUntil(...)` has therefore been changedin 1.11.3 so that if the worker
* shuts down before the inner promise completes, `runUntil` will allow no more than a certain delay
* (i.e. `promiseCompletionTimeout`) for the inner promise to complete, after which a
* {@link PromiseCompletionTimeoutError} is thrown.
*
* In most practical use cases, no delay is actually required; `promiseCompletionTimeout` therefore
* defaults to 0 second, meaning the Worker will not wait for the inner promise to complete.
* You may adjust this value in the very rare cases where waiting is pertinent; set it to a
* very high value to mimic the previous behavior.
*
* This time is calculated from the moment the Worker reachs either the `STOPPED` or the `FAILED`
* state. {@link Worker.runUntil} throws a {@link PromiseCompletionTimeoutError} if the if the
* Promise still hasn't completed after that delay.
*
* @default 0 don't wait
*/
promiseCompletionTimeout?: Duration;
}
/**
* The temporal Worker connects to Temporal Server and runs Workflows and Activities.
*/
export class Worker {
protected readonly activityHeartbeatSubject = new Subject<HeartbeatInput>();
protected readonly stateSubject = new BehaviorSubject<State>('INITIALIZED');
// Pushing an error to this subject causes the Worker to initiate a graceful shutdown, after
// which the Worker will be in FAILED state and the `run` promise will throw the first error
// published on this subject.
protected readonly unexpectedErrorSubject = new Subject<void>();
// Pushing an error to this subject will cause the worker to IMMEDIATELY fall into FAILED state.
//
// The `run` promise will throw the first error reported on either this subject or the
// `unexpectedErrorSubject` subject. That is, suppose that an "unexpected error" comes in,
// which triggers graceful shutdown of the Worker, and then, while attempting to gracefully shut
// down the Worker, we get some "instant terminate error". The Worker's `run` promise will throw
// the _initial error_ rather than the "instant terminate error" that came later. This is so to
// avoid masking the original error with a subsequent one that will likely be less relevant.
// Both errors will still be reported to the logger.
protected readonly instantTerminateErrorSubject = new Subject<void>();
protected readonly workflowPollerStateSubject = new BehaviorSubject<PollerState>('POLLING');
protected readonly activityPollerStateSubject = new BehaviorSubject<PollerState>('POLLING');
/**
* Whether or not this worker has an outstanding workflow poll request
*/
protected hasOutstandingWorkflowPoll = false;
/**
* Whether or not this worker has an outstanding activity poll request
*/
protected hasOutstandingActivityPoll = false;
protected readonly numInFlightActivationsSubject = new BehaviorSubject<number>(0);
protected readonly numInFlightActivitiesSubject = new BehaviorSubject<number>(0);
protected readonly numInFlightNonLocalActivitiesSubject = new BehaviorSubject<number>(0);
protected readonly numInFlightLocalActivitiesSubject = new BehaviorSubject<number>(0);
protected readonly numCachedWorkflowsSubject = new BehaviorSubject<number>(0);
protected readonly numHeartbeatingActivitiesSubject = new BehaviorSubject<number>(0);
protected readonly evictionsEmitter = new EventEmitter();
protected static nativeWorkerCtor: NativeWorkerConstructor = NativeWorker;
// Used to add uniqueness to replay worker task queue names
protected static replayWorkerCount = 0;
private static readonly SELF_INDUCED_SHUTDOWN_EVICTION: RemoveFromCache = {
message: 'Shutting down',
reason: EvictionReason.FATAL,
};
protected readonly workflowCodecRunner: WorkflowCodecRunner;
/**
* Create a new Worker.
* This method initiates a connection to the server and will throw (asynchronously) on connection failure.
*/
public static async create(options: WorkerOptions): Promise<Worker> {
const runtime = Runtime.instance();
const logger = LoggerWithComposedMetadata.compose(runtime.logger, {
sdkComponent: SdkComponent.worker,
taskQueue: options.taskQueue ?? 'default',
});
const metricMeter = runtime.metricMeter.withTags({
namespace: options.namespace ?? 'default',
taskQueue: options.taskQueue ?? 'default',
});
const nativeWorkerCtor: NativeWorkerConstructor = this.nativeWorkerCtor;
const compiledOptions = compileWorkerOptions(options, logger, metricMeter);
logger.debug('Creating worker', {
options: {
...compiledOptions,
...(compiledOptions.workflowBundle && isCodeBundleOption(compiledOptions.workflowBundle)
? {
// Avoid dumping workflow bundle code to the console
workflowBundle: <WorkflowBundle>{
code: `<string of length ${compiledOptions.workflowBundle.code.length}>`,
},
}
: {}),
},
});
const bundle = await this.getOrCreateBundle(compiledOptions, logger);
let workflowCreator: WorkflowCreator | undefined = undefined;
if (bundle) {
workflowCreator = await this.createWorkflowCreator(bundle, compiledOptions, logger);
}
// Create a new connection if one is not provided with no CREATOR reference
// so it can be automatically closed when this Worker shuts down.
const connection = options.connection ?? (await InternalNativeConnection.connect());
let nativeWorker: NativeWorkerLike;
const compiledOptionsWithBuildId = addBuildIdIfMissing(compiledOptions, bundle?.code);
try {
nativeWorker = await nativeWorkerCtor.create(runtime, connection, compiledOptionsWithBuildId);
} catch (err) {
// We just created this connection, close it
if (!options.connection) {
await connection.close();
}
throw err;
}
extractReferenceHolders(connection).add(nativeWorker);
return new this(
runtime,
nativeWorker,
workflowCreator,
compiledOptionsWithBuildId,
logger,
metricMeter,
connection
);
}
protected static async createWorkflowCreator(
workflowBundle: WorkflowBundleWithSourceMapAndFilename,
compiledOptions: CompiledWorkerOptions,
logger: Logger
): Promise<WorkflowCreator> {
const registeredActivityNames = new Set(compiledOptions.activities.keys());
// This isn't required for vscode, only for Chrome Dev Tools which doesn't support debugging worker threads.
// We also rely on this in debug-replayer where we inject a global variable to be read from workflow context.
if (compiledOptions.debugMode) {
if (compiledOptions.reuseV8Context) {
return await ReusableVMWorkflowCreator.create(
workflowBundle,
compiledOptions.isolateExecutionTimeoutMs,
registeredActivityNames
);
}
return await VMWorkflowCreator.create(
workflowBundle,
compiledOptions.isolateExecutionTimeoutMs,
registeredActivityNames
);
} else {
return await ThreadedVMWorkflowCreator.create({
workflowBundle,
threadPoolSize: compiledOptions.workflowThreadPoolSize,
isolateExecutionTimeoutMs: compiledOptions.isolateExecutionTimeoutMs,
reuseV8Context: compiledOptions.reuseV8Context ?? true,
registeredActivityNames,
logger,
});
}
}
/**
* Create a replay Worker, and run the provided history against it. Will resolve as soon as
* the history has finished being replayed, or if the workflow produces a nondeterminism error.
*
* @param workflowId If provided, use this as the workflow id during replay. Histories do not
* contain a workflow id, so it must be provided separately if your workflow depends on it.
* @throws {@link DeterminismViolationError} if the workflow code is not compatible with the history.
* @throws {@link ReplayError} on any other replay related error.
*/
public static async runReplayHistory(
options: ReplayWorkerOptions,
history: History | unknown,
workflowId?: string
): Promise<void> {
const validated = this.validateHistory(history);
const result = await this.runReplayHistories(options, [
{ history: validated, workflowId: workflowId ?? 'fake' },
]).next();
if (result.done) throw new IllegalStateError('Expected at least one replay result');
if (result.value.error) throw result.value.error;
}
/**
* Create a replay Worker, running all histories provided by the passed in iterable.
*
* Returns an async iterable of results for each history replayed.
*/
public static async *runReplayHistories(
options: ReplayWorkerOptions,
histories: ReplayHistoriesIterable
): AsyncIterableIterator<ReplayResult> {
const [worker, pusher] = await this.constructReplayWorker(options);
const rt = worker.runtime;
const evictions = on(worker.evictionsEmitter, 'eviction') as AsyncIterableIterator<[EvictionWithRunID]>;
const runPromise = worker.run().then(() => {
throw new ShutdownError('Worker was shutdown');
});
void runPromise.catch(() => {
// ignore to avoid unhandled rejections
});
let innerError = undefined;
try {
try {
for await (const { history, workflowId } of histories) {
const validated = this.validateHistory(history);
await rt.pushHistory(pusher, workflowId, validated);
const next = await Promise.race([evictions.next(), runPromise]);
if (next.done) {
break; // This shouldn't happen, handle just in case
}
const [{ runId, evictJob }] = next.value;
const error = evictionReasonToReplayError(evictJob);
// We replay one workflow at a time so the workflow ID comes from the histories iterable.
yield {
workflowId,
runId,
error,
};
}
} catch (err) {
innerError = err;
}
} finally {
try {
rt.closeHistoryStream(pusher);
worker.shutdown();
} catch {
// ignore in case worker was already shutdown
}
try {
await runPromise;
} catch (err) {
/* eslint-disable no-unsafe-finally */
if (err instanceof ShutdownError) {
if (innerError !== undefined) throw innerError;
return;
} else if (innerError === undefined) {
throw err;
} else {
throw new CombinedWorkerRunError('Worker run failed with inner error', {
cause: {
workerError: err,
innerError,
},
});
}
/* eslint-enable no-unsafe-finally */
}
}
}
private static validateHistory(history: unknown): History {
if (typeof history !== 'object' || history == null) {
throw new TypeError(`Expected a non-null history object, got ${typeof history}`);
}
const { eventId } = (history as any).events[0];
// in a "valid" history, eventId would be Long
if (typeof eventId === 'string') {
return historyFromJSON(history);
} else {
return history;
}
}
private static async constructReplayWorker(options: ReplayWorkerOptions): Promise<[Worker, native.HistoryPusher]> {
const nativeWorkerCtor: NativeWorkerConstructor = this.nativeWorkerCtor;
const fixedUpOptions: WorkerOptions = {
taskQueue: (options.replayName ?? 'fake_replay_queue') + '-' + this.replayWorkerCount,
debugMode: true,
...options,
};
this.replayWorkerCount++;
const runtime = Runtime.instance();
const logger = LoggerWithComposedMetadata.compose(runtime.logger, {
sdkComponent: 'worker',
taskQueue: fixedUpOptions.taskQueue,
});
const metricMeter = runtime.metricMeter.withTags({
namespace: 'default',
taskQueue: fixedUpOptions.taskQueue,
});
const compiledOptions = compileWorkerOptions(fixedUpOptions, logger, metricMeter);
const bundle = await this.getOrCreateBundle(compiledOptions, logger);
if (!bundle) {
throw new TypeError('ReplayWorkerOptions must contain workflowsPath or workflowBundle');
}
const workflowCreator = await this.createWorkflowCreator(bundle, compiledOptions, logger);
const replayHandle = await nativeWorkerCtor.createReplay(
runtime,
addBuildIdIfMissing(compiledOptions, bundle.code)
);
return [
new this(runtime, replayHandle.worker, workflowCreator, compiledOptions, logger, metricMeter, undefined, true),
replayHandle.historyPusher,
];
}
protected static async getOrCreateBundle(
compiledOptions: CompiledWorkerOptions,
logger: Logger
): Promise<WorkflowBundleWithSourceMapAndFilename | undefined> {
if (compiledOptions.workflowBundle) {
if (compiledOptions.workflowsPath) {
logger.warn('Ignoring WorkerOptions.workflowsPath because WorkerOptions.workflowBundle is set');
}
if (compiledOptions.bundlerOptions) {
logger.warn('Ignoring WorkerOptions.bundlerOptions because WorkerOptions.workflowBundle is set');
}
const modules = new Set(compiledOptions.interceptors.workflowModules);
// Warn if user tries to customize the default set of workflow interceptor modules
if (
modules &&
new Set([...modules, ...defaultWorkflowInterceptorModules]).size !== defaultWorkflowInterceptorModules.length
) {
logger.warn(
'Ignoring WorkerOptions.interceptors.workflowModules because WorkerOptions.workflowBundle is set.\n' +
'To use workflow interceptors with a workflowBundle, pass them in the call to bundleWorkflowCode.'
);
}
if (isCodeBundleOption(compiledOptions.workflowBundle)) {
return parseWorkflowCode(compiledOptions.workflowBundle.code);
} else if (isPathBundleOption(compiledOptions.workflowBundle)) {
const code = await fs.readFile(compiledOptions.workflowBundle.codePath, 'utf8');
return parseWorkflowCode(code, compiledOptions.workflowBundle.codePath);
} else {
throw new TypeError('Invalid WorkflowOptions.workflowBundle');
}
} else if (compiledOptions.workflowsPath) {
const bundler = new WorkflowCodeBundler({
logger,
workflowsPath: compiledOptions.workflowsPath,
workflowInterceptorModules: compiledOptions.interceptors.workflowModules,
failureConverterPath: compiledOptions.dataConverter?.failureConverterPath,
payloadConverterPath: compiledOptions.dataConverter?.payloadConverterPath,
ignoreModules: compiledOptions.bundlerOptions?.ignoreModules,
webpackConfigHook: compiledOptions.bundlerOptions?.webpackConfigHook,
});
const bundle = await bundler.createBundle();
return parseWorkflowCode(bundle.code);
} else {
return undefined;
}
}
/**
* Create a new Worker from nativeWorker.
*/
protected constructor(
protected readonly runtime: Runtime,
protected readonly nativeWorker: NativeWorkerLike,
/**
* Optional WorkflowCreator - if not provided, Worker will not poll on Workflows
*/
protected readonly workflowCreator: WorkflowCreator | undefined,
public readonly options: CompiledWorkerOptions,
/** Logger bound to 'sdkComponent: worker' */
protected readonly logger: Logger,
protected readonly metricMeter: MetricMeter,
protected readonly connection?: NativeConnection,
protected readonly isReplayWorker: boolean = false
) {
this.workflowCodecRunner = new WorkflowCodecRunner(options.loadedDataConverter.payloadCodecs);
}
/**
* An Observable which emits each time the number of in flight activations changes
*/
public get numInFlightActivations$(): Observable<number> {
return this.numInFlightActivationsSubject;
}
/**
* An Observable which emits each time the number of in flight Activity tasks changes
*/
public get numInFlightActivities$(): Observable<number> {
return this.numInFlightActivitiesSubject;
}
/**
* An Observable which emits each time the number of cached workflows changes
*/
public get numRunningWorkflowInstances$(): Observable<number> {
return this.numCachedWorkflowsSubject;
}
/**
* Get the poll state of this worker
*/
public getState(): State {
// Setters and getters require the same visibility, add this public getter function
return this.stateSubject.getValue();
}
/**
* Get a status overview of this Worker
*/
public getStatus(): WorkerStatus {
return {
runState: this.state,
numHeartbeatingActivities: this.numHeartbeatingActivitiesSubject.value,
workflowPollerState: this.workflowPollerStateSubject.value,
activityPollerState: this.activityPollerStateSubject.value,
hasOutstandingWorkflowPoll: this.hasOutstandingWorkflowPoll,
hasOutstandingActivityPoll: this.hasOutstandingActivityPoll,
numCachedWorkflows: this.numCachedWorkflowsSubject.value,
numInFlightWorkflowActivations: this.numInFlightActivationsSubject.value,
numInFlightActivities: this.numInFlightActivitiesSubject.value,
numInFlightNonLocalActivities: this.numInFlightNonLocalActivitiesSubject.value,
numInFlightLocalActivities: this.numInFlightLocalActivitiesSubject.value,
};
}
protected get state(): State {
return this.stateSubject.getValue();
}
protected set state(state: State) {
this.logger.info('Worker state changed', { state });
this.stateSubject.next(state);
}
/**
* Start shutting down the Worker. The Worker stops polling for new tasks and sends
* {@link https://typescript.temporal.io/api/namespaces/activity#cancellation | cancellation}
* (via a {@link CancelledFailure} with `message` set to `'WORKER_SHUTDOWN'`) to running Activities.
* Note: if the Activity accepts cancellation (i.e. re-throws or allows the `CancelledFailure`
* to be thrown out of the Activity function), the Activity Task will be marked as failed, not
* cancelled. It's helpful for the Activity Task to be marked failed during shutdown because the
* Server will retry the Activity sooner (than if the Server had to wait for the Activity Task
* to time out).
*
* When called, immediately transitions {@link state} to `'STOPPING'` and asks Core to shut down.
* Once Core has confirmed that it's shutting down, the Worker enters `'DRAINING'` state. It will
* stay in that state until both task pollers receive a `ShutdownError`, at which point we'll
* transition to `DRAINED` state. Once all currently running Activities and Workflow Tasks have
* completed, the Worker transitions to `'STOPPED'`.
*/
shutdown(): void {
if (this.state !== 'RUNNING') {
throw new IllegalStateError(`Not running. Current state: ${this.state}`);
}
this.state = 'STOPPING';
try {
this.nativeWorker.initiateShutdown();
this.state = 'DRAINING';
} catch (error) {
// This is totally unexpected, and indicates there's something horribly wrong with the Worker
// state. Attempt to shutdown gracefully will very likely hang, so just terminate immediately.
this.logger.error('Failed to initiate shutdown', { error });
this.instantTerminateErrorSubject.error(error);
}
}
/**
* An observable that completes when {@link state} becomes `'DRAINED'` or throws if {@link state} transitions to
* `'STOPPING'` and remains that way for {@link this.options.shutdownForceTimeMs}.
*/
protected forceShutdown$(): Observable<never> {
if (this.options.shutdownForceTimeMs == null) {
return EMPTY;
}
return race(
this.stateSubject.pipe(
filter((state): state is 'STOPPING' => state === 'STOPPING'),
delay(this.options.shutdownForceTimeMs),
tap({
next: () => {
// Inject the error into the instantTerminateError subject so that we don't mask
// any error that might have caused the Worker to shutdown in the first place.
this.logger.debug('Shutdown force time expired, terminating worker');
this.instantTerminateErrorSubject.error(
new GracefulShutdownPeriodExpiredError('Timed out while waiting for worker to shutdown gracefully')
);
},
})
),
this.stateSubject.pipe(
filter((state) => state === 'DRAINED'),
first()
)
).pipe(ignoreElements());
}
/**
* An observable which repeatedly polls for new tasks unless worker becomes suspended.
* The observable stops emitting once core is shutting down.
*/
protected pollLoop$<T>(pollFn: () => Promise<T>): Observable<T> {
return from(
(async function* () {
for (;;) {
try {
yield await pollFn();
} catch (err) {
if (err instanceof ShutdownError) {
break;
}
throw err;
}
}
})()
);
}
/**
* Process Activity tasks
*/
protected activityOperator(): OperatorFunction<ActivityTaskWithBase64Token, Uint8Array> {
return pipe(
closeableGroupBy(({ base64TaskToken }) => base64TaskToken),
mergeMap((group$) => {
return group$.pipe(
mergeMapWithState(
async (activity: Activity | undefined, { task, base64TaskToken, protobufEncodedTask }) => {
const { taskToken, variant } = task;
if (!variant) {
throw new TypeError('Got an activity task without a "variant" attribute');
}
// We either want to return an activity result (for failures) or pass on the activity for running at a later stage
// If cancel is requested we ignore the result of this function
// We don't run the activity directly in this operator because we need to return the activity in the state
// so it can be cancelled if requested
let output:
| {
type: 'result';
result: coresdk.activity_result.IActivityExecutionResult;
}
| {
type: 'run';
activity: Activity;
input: ActivityExecuteInput;
}
| { type: 'ignore' };
switch (variant) {
case 'start': {
let info: ActivityInfo | undefined = undefined;
try {
if (activity !== undefined) {
throw new IllegalStateError(
`Got start event for an already running activity: ${base64TaskToken}`
);
}
info = await extractActivityInfo(
task,
this.options.loadedDataConverter,
this.options.namespace,
this.options.taskQueue
);
const { activityType } = info;
// Use the corresponding activity if it exists, otherwise, fallback to default activity function (if exists)
const fn = this.options.activities.get(activityType) ?? this.options.activities.get('default');
if (typeof fn !== 'function') {
throw ApplicationFailure.create({
type: 'NotFoundError',
message: `Activity function ${activityType} is not registered on this Worker, available activities: ${JSON.stringify(
[...this.options.activities.keys()]
)}`,
nonRetryable: false,
});
}
let args: unknown[];
try {
args = await decodeArrayFromPayloads(this.options.loadedDataConverter, task.start?.input);
} catch (err) {
throw ApplicationFailure.fromError(err, {
message: `Failed to parse activity args for activity ${activityType}: ${errorMessage(err)}`,
nonRetryable: false,
});
}
const headers = task.start?.headerFields ?? {};
const input = {
args,
headers,
};
this.logger.trace('Starting activity', activityLogAttributes(info));
activity = new Activity(
info,
fn,
this.options.loadedDataConverter,
(details) =>
this.activityHeartbeatSubject.next({
type: 'heartbeat',
info: info!,
taskToken,
base64TaskToken,
details,
onError() {
activity?.cancel('HEARTBEAT_DETAILS_CONVERSION_FAILED'); // activity must be defined
},
}),
this.logger,
this.metricMeter,
this.options.interceptors.activity
);
output = { type: 'run', activity, input };
break;
} catch (e) {
const error = ensureApplicationFailure(e);
this.logger.error(`Error while processing ActivityTask.start: ${errorMessage(error)}`, {
...(info ? activityLogAttributes(info) : {}),
error: e,
task: JSON.stringify(task.toJSON()),
taskEncoded: Buffer.from(protobufEncodedTask).toString('base64'),
});
output = {
type: 'result',
result: {
failed: {
failure: await encodeErrorToFailure(this.options.loadedDataConverter, error),
},
},
};
break;
}
}
case 'cancel': {
output = { type: 'ignore' };
if (activity === undefined) {
this.logger.trace('Tried to cancel a non-existing activity', {
taskToken: base64TaskToken,
});
break;
}
// NOTE: activity will not be considered cancelled until it confirms cancellation (by throwing a CancelledFailure)
this.logger.trace('Cancelling activity', activityLogAttributes(activity.info));
const reason = task.cancel?.reason;
if (reason === undefined || reason === null) {
// Special case of Lang side cancellation during shutdown (see `activity.shutdown.evict` above)
activity.cancel('WORKER_SHUTDOWN');
} else {
activity.cancel(coresdk.activity_task.ActivityCancelReason[reason] as CancelReason);
}
break;
}
}
return { state: activity, output: { taskToken, output } };
},
undefined // initial value
),
mergeMap(async ({ output, taskToken }) => {
if (output.type === 'ignore') {
return undefined;
}
if (output.type === 'result') {
return { taskToken, result: output.result };
}
const { base64TaskToken } = output.activity.info;
this.activityHeartbeatSubject.next({
type: 'create',
base64TaskToken,
});
let result;
const numInFlightBreakdownSubject = output.activity.info.isLocal
? this.numInFlightLocalActivitiesSubject
: this.numInFlightNonLocalActivitiesSubject;
this.numInFlightActivitiesSubject.next(this.numInFlightActivitiesSubject.value + 1);
numInFlightBreakdownSubject.next(numInFlightBreakdownSubject.value + 1);
try {
result = await output.activity.run(output.input);
} finally {
numInFlightBreakdownSubject.next(numInFlightBreakdownSubject.value - 1);
this.numInFlightActivitiesSubject.next(this.numInFlightActivitiesSubject.value - 1);
}
const status = result.failed ? 'failed' : result.completed ? 'completed' : 'cancelled';
if (status === 'failed') {
// Make sure to flush the last heartbeat
this.logger.trace('Activity failed, waiting for heartbeats to be flushed', {
...activityLogAttributes(output.activity.info),
status,
});
await new Promise<void>((resolve) => {
this.activityHeartbeatSubject.next({
type: 'completion',
flushRequired: true,
base64TaskToken,
callback: resolve,
});
});
} else {
// Notify the Activity heartbeat state mapper that the Activity has completed
this.activityHeartbeatSubject.next({
type: 'completion',
flushRequired: false,
base64TaskToken,
callback: () => undefined,
});
}
this.logger.trace('Activity resolved', {
...activityLogAttributes(output.activity.info),
status,
});
return { taskToken, result };
}),
filter(<T>(result: T): result is Exclude<T, undefined> => result !== undefined),
map((rest) => coresdk.ActivityTaskCompletion.encodeDelimited(rest).finish()),
tap({
next: () => {
group$.close();
},
})
);
})
);
}
/**
* Process activations from the same workflow execution to an observable of completions.
*
* Injects a synthetic eviction activation when the worker transitions to no longer polling.
*/
protected handleWorkflowActivations(
activations$: CloseableGroupedObservable<string, coresdk.workflow_activation.WorkflowActivation>
): Observable<Uint8Array> {
const syntheticEvictionActivations$ = this.workflowPollerStateSubject.pipe(
// Core has indicated that it will not return any more poll results; evict all cached WFs.
filter((state) => state !== 'POLLING'),
first(),
map(() => ({
activation: coresdk.workflow_activation.WorkflowActivation.create({
runId: activations$.key,
jobs: [{ removeFromCache: Worker.SELF_INDUCED_SHUTDOWN_EVICTION }],
}),
synthetic: true,
})),
takeUntil(activations$.pipe(last(undefined, null)))
);
const activations$$ = activations$.pipe(map((activation) => ({ activation, synthetic: false })));
return merge(activations$$, syntheticEvictionActivations$).pipe(
tap(() => {
this.numInFlightActivationsSubject.next(this.numInFlightActivationsSubject.value + 1);
}),
mergeMapWithState(this.handleActivation.bind(this), undefined),
tap(({ close }) => {
this.numInFlightActivationsSubject.next(this.numInFlightActivationsSubject.value - 1);
if (close) {
activations$.close();
this.numCachedWorkflowsSubject.next(this.numCachedWorkflowsSubject.value - 1);
}
}),
takeWhile(({ close }) => !close, true /* inclusive */),
map(({ completion }) => completion),
filter((result): result is Uint8Array => result !== undefined)
);
}
/**
* Process a single activation to a completion.
*/
protected async handleActivation(
workflow: WorkflowWithLogAttributes | undefined,
{ activation, synthetic }: { activation: coresdk.workflow_activation.WorkflowActivation; synthetic: boolean }
): Promise<{
state: WorkflowWithLogAttributes | undefined;
output: { completion?: Uint8Array; close: boolean };
}> {
try {
const removeFromCacheIx = activation.jobs.findIndex(({ removeFromCache }) => removeFromCache);
const close = removeFromCacheIx !== -1;
const jobs = activation.jobs;
if (close) {
const asEvictJob = jobs.splice(removeFromCacheIx, 1)[0].removeFromCache;
if (asEvictJob) {
this.evictionsEmitter.emit('eviction', {
runId: activation.runId,
evictJob: asEvictJob,
} as EvictionWithRunID);
}
}
activation.jobs = jobs;
if (jobs.length === 0) {
this.logger.trace('Disposing workflow', workflow ? workflow.logAttributes : { runId: activation.runId });
await workflow?.workflow.dispose();
if (!close) {
throw new IllegalStateError('Got a Workflow activation with no jobs');
}
const completion = synthetic
? undefined
: coresdk.workflow_completion.WorkflowActivationCompletion.encodeDelimited({
runId: activation.runId,
successful: {},
}).finish();
return { state: undefined, output: { close, completion } };
}
const decodedActivation = await this.workflowCodecRunner.decodeActivation(activation);
if (workflow === undefined) {
const initWorkflowDetails = decodedActivation.jobs[0]?.initializeWorkflow;
if (initWorkflowDetails == null)
throw new IllegalStateError(
'Received workflow activation for an untracked workflow with no init workflow job'
);
workflow = await this.createWorkflow(decodedActivation, initWorkflowDetails);
}
let isFatalError = false;
try {
const unencodedCompletion = await workflow.workflow.activate(decodedActivation);
const completion = await this.workflowCodecRunner.encodeCompletion(unencodedCompletion);
return { state: workflow, output: { close, completion } };
} catch (err) {
if (err instanceof UnexpectedError) {
isFatalError = true;
}
throw err;
} finally {
// Fatal error means we cannot call into this workflow again unfortunately
if (!isFatalError) {
// When processing workflows through runReplayHistories, Core may still send non-replay
// activations on the very last Workflow Task in some cases. Though Core is technically exact
// here, the fact that sinks marked with callDuringReplay = false may get called on a replay
// worker is definitely a surprising behavior. For that reason, we extend the isReplaying flag in
// this case to also include anything running under in a replay worker.
const isReplaying = activation.isReplaying || this.isReplayWorker;
const calls = await workflow.workflow.getAndResetSinkCalls();
await this.processSinkCalls(calls, isReplaying, workflow.logAttributes);
}
this.logger.trace('Completed activation', workflow.logAttributes);
}
} catch (error) {
let logMessage = 'Failed to process Workflow Activation';
if (error instanceof UnexpectedError) {
// Something went wrong in the workflow; we'll do our best to shut the Worker
// down gracefully, but then we'll need to terminate the Worker ASAP.
logMessage = 'An unexpected error occured while processing Workflow Activation. Initiating Worker shutdown.';
this.unexpectedErrorSubject.error(error);
}
this.logger.error(logMessage, {
runId: activation.runId,
...workflow?.logAttributes,
error,
workflowExists: workflow !== undefined,
});
const completion = coresdk.workflow_completion.WorkflowActivationCompletion.encodeDelimited({
runId: activation.runId,
failed: {
failure: await encodeErrorToFailure(this.options.loadedDataConverter, error),
},
}).finish();
// We do not dispose of the Workflow yet, wait to be evicted from Core.
// This is done to simplify the Workflow lifecycle so Core is the sole driver.
return {