@temporalio/worker
Version:
Temporal.io SDK Worker sub-package
428 lines • 21.4 kB
JavaScript
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.BaseVMWorkflow = exports.globalHandlers = exports.GlobalHandlers = void 0;
exports.setUnhandledRejectionHandler = setUnhandledRejectionHandler;
exports.injectGlobals = injectGlobals;
const node_v8_1 = __importDefault(require("node:v8"));
const node_async_hooks_1 = require("node:async_hooks");
const node_assert_1 = __importDefault(require("node:assert"));
const node_url_1 = require("node:url");
const node_util_1 = require("node:util");
const source_map_1 = require("source-map");
const common_1 = require("@temporalio/common");
const time_1 = require("@temporalio/common/lib/time");
const proto_1 = require("@temporalio/proto");
const flags_1 = require("@temporalio/workflow/lib/flags");
const errors_1 = require("../errors");
const utils_1 = require("../utils");
// Best effort to catch unhandled rejections from workflow code.
// We crash the thread if we cannot find the culprit.
function setUnhandledRejectionHandler(getWorkflowByRunId) {
process.on('unhandledRejection', (err, promise) => {
const activator = getActivator(promise);
const runId = activator?.info?.runId;
if (runId !== undefined) {
const workflow = getWorkflowByRunId(runId);
if (workflow !== undefined) {
workflow.setUnhandledRejection(new errors_1.UnhandledRejectionError(`Unhandled Promise rejection: ${err}`, err));
return;
}
}
console.error('An Unhandled Promise rejection could not be associated to a Workflow Run', { runId, error: err });
throw new errors_1.UnhandledRejectionError(`Unhandled Promise rejection for unknown Workflow Run id='${runId}': ${err}`, err);
});
}
/**
* Variant of {@link cutoffStackTrace} that works with FileLocation, keep this in sync with the original implementation
*/
function cutoffStructuredStackTrace(stackTrace) {
stackTrace.shift();
if (stackTrace[0].function_name === 'initAll' && stackTrace[0].file_path === 'node:internal/promise_hooks') {
stackTrace.shift();
}
const idx = stackTrace.findIndex(({ function_name, file_path }) => {
return (function_name &&
file_path &&
((/^Activator\.\S+NextHandler$/.test(function_name) &&
/.*[\\/]workflow[\\/](?:src|lib)[\\/]internals\.[jt]s$/.test(file_path)) ||
(/Script\.runInContext/.test(function_name) && /^node:vm|vm\.js$/.test(file_path))));
});
if (idx > -1) {
stackTrace.splice(idx);
}
}
function getActivator(promise) {
// Access the global scope associated with the promise (unique per workflow - vm.context)
// See for reference https://github.com/patriksimek/vm2/issues/32
const ctor = promise.constructor.constructor;
return ctor('return globalThis.__TEMPORAL_ACTIVATOR__')();
}
/**
* Internal helper to format callsite "name" portion in stack trace
*/
function formatCallsiteName(callsite) {
const typeName = callsite.getTypeName();
const methodName = callsite.getMethodName();
const functionName = callsite.getFunctionName();
const isConstructor = callsite.isConstructor();
return typeName && methodName
? `${typeName}.${methodName}`
: isConstructor && functionName
? `new ${functionName}`
: functionName;
}
/**
* Inject global objects as well as console.[log|...] into a vm context.
*/
function injectGlobals(context) {
const globals = {
AsyncLocalStorage: node_async_hooks_1.AsyncLocalStorage,
URL: node_url_1.URL,
URLSearchParams: node_url_1.URLSearchParams,
assert: node_assert_1.default,
TextEncoder: node_util_1.TextEncoder,
TextDecoder: node_util_1.TextDecoder,
AbortController,
};
for (const [k, v] of Object.entries(globals)) {
Object.defineProperty(context, k, { value: v, writable: false, enumerable: true, configurable: false });
}
const consoleMethods = ['log', 'warn', 'error', 'info', 'debug'];
function makeConsoleFn(level) {
return function (...args) {
const { info } = context.__TEMPORAL_ACTIVATOR__;
if (info.isReplaying)
return;
console[level](`[${info.workflowType}(${info.workflowId})]`, ...args);
};
}
const consoleObject = Object.fromEntries(consoleMethods.map((level) => [level, makeConsoleFn(level)]));
Object.defineProperty(context, 'console', {
value: consoleObject,
writable: true,
enumerable: false,
configurable: true,
});
}
/**
* Global handlers for overriding stack trace preparation and promise hooks
*/
class GlobalHandlers {
currentStackTrace = undefined;
bundleFilenameToSourceMapConsumer = new Map();
origPrepareStackTrace = Error.prepareStackTrace;
stopPromiseHook = () => { };
installed = false;
async addWorkflowBundle(workflowBundle) {
const sourceMapConsumer = await new source_map_1.SourceMapConsumer(workflowBundle.sourceMap);
this.bundleFilenameToSourceMapConsumer.set(workflowBundle.filename, sourceMapConsumer);
}
removeWorkflowBundle(workflowBundle) {
this.bundleFilenameToSourceMapConsumer.delete(workflowBundle.filename);
}
/**
* Set the global hooks, this method is idempotent
*/
install() {
if (!this.installed) {
this.overridePrepareStackTrace();
this.setPromiseHook();
this.installed = true;
}
}
/**
* Unset all installed global hooks
*
* This method is not called anywhere since we typically install the hooks in a separate thread which is cleaned up
* after worker shutdown. Is debug mode we don't clean these up but that should be insignificant.
*/
uninstall() {
this.stopPromiseHook();
Error.prepareStackTrace = this.origPrepareStackTrace;
this.installed = false;
}
overridePrepareStackTrace() {
const OuterError = Error;
// Augment the vm-global Error stack trace prepare function
// NOTE: this means that multiple instances of this class in the same VM
// will override each other.
// This should be a non-issue in most cases since we typically construct a single instance of
// this class per Worker thread.
// See: https://v8.dev/docs/stack-trace-api#customizing-stack-traces
Error.prepareStackTrace = (err, stackTraces) => {
const inWorkflowContext = OuterError !== err.constructor;
if (this.origPrepareStackTrace && !inWorkflowContext) {
return this.origPrepareStackTrace(err, stackTraces);
}
// Set the currentStackTrace so it can be used in the promise `init` hook below
this.currentStackTrace = [];
const converted = stackTraces.map((callsite) => {
const line = callsite.getLineNumber();
const column = callsite.getColumnNumber();
const filename = callsite.getFileName();
const sourceMapConsumer = filename && this.bundleFilenameToSourceMapConsumer.get(filename);
if (sourceMapConsumer && line && column) {
const pos = sourceMapConsumer.originalPositionFor({ line, column });
const name = pos.name || formatCallsiteName(callsite);
this.currentStackTrace?.push({
file_path: pos.source ?? undefined,
function_name: name ?? undefined,
line: pos.line ?? undefined,
column: pos.column ?? undefined,
internal_code: false,
});
return name
? ` at ${name} (${pos.source}:${pos.line}:${pos.column})`
: ` at ${pos.source}:${pos.line}:${pos.column}`;
}
else {
const name = formatCallsiteName(callsite);
this.currentStackTrace?.push({
file_path: filename ?? undefined,
function_name: name ?? undefined,
line: line ?? undefined,
column: column ?? undefined,
internal_code: false,
});
return ` at ${callsite}`;
}
});
return `${err}\n${converted.join('\n')}`;
};
}
setPromiseHook() {
// Track Promise aggregators like `race` and `all` to link their internally created promises
let currentAggregation = undefined;
// This also is set globally for the isolate (worker thread), which is insignificant unless the worker is run in debug mode
try {
this.stopPromiseHook = node_v8_1.default.promiseHooks.createHook({
init: (promise, parent) => {
// Only run in workflow context
const activator = getActivator(promise);
if (!activator)
return;
const store = activator.promiseStackStore;
// TODO: hide this somehow: defineProperty + symbol
promise.runId = activator.info.runId;
// Reset currentStackTrace just in case (it will be set in `prepareStackTrace` above)
this.currentStackTrace = undefined;
const fn = promise.constructor.constructor;
const ErrorCtor = fn('return globalThis.Error')();
// To see the full stack replace with commented line
// const formatted = new ErrorCtor().stack?.replace(/^Error\n\s*at [^\n]+\n(\s*at initAll \(node:internal\/promise_hooks:\d+:\d+\)\n)?/, '')!;
const formatted = (0, common_1.cutoffStackTrace)(new ErrorCtor().stack?.replace(/^Error\n\s*at [^\n]+\n(\s*at initAll \(node:internal\/promise_hooks:\d+:\d+\)\n)?/, ''));
if (this.currentStackTrace === undefined) {
return;
}
const structured = this.currentStackTrace;
cutoffStructuredStackTrace(structured);
let stackTrace = { formatted, structured };
if (currentAggregation &&
/^\s+at\sPromise\.then \(<anonymous>\)\n\s+at Function\.(race|all|allSettled|any) \(<anonymous>\)\n/.test(formatted)) {
// Skip internal promises created by the aggregator and link directly.
promise = currentAggregation;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
stackTrace = store.promiseToStack.get(currentAggregation); // Must exist
}
else if (/^\s+at Function\.(race|all|allSettled|any) \(<anonymous>\)\n/.test(formatted)) {
currentAggregation = promise;
}
else {
currentAggregation = undefined;
}
// This is weird but apparently it happens
if (promise === parent) {
return;
}
store.promiseToStack.set(promise, stackTrace);
// In case of Promise.race and friends we might have multiple "parents"
const parents = store.childToParent.get(promise) ?? new Set();
if (parent) {
parents.add(parent);
}
store.childToParent.set(promise, parents);
},
settled(promise) {
// Only run in workflow context
const store = getActivator(promise)?.promiseStackStore;
if (!store)
return;
store.childToParent.delete(promise);
store.promiseToStack.delete(promise);
},
});
}
catch (_) {
// v8.promiseHooks.createHook is not available in bun and Node.js < 16.14.0.
// That's ok, collecting stack trace is an optional feature anyway.
//
// FIXME: This should be sent to logs, not the console… but we don't have access to it here.
console.warn('v8.promiseHooks.createHook is not available; stack trace collection will be disabled.');
}
}
}
exports.GlobalHandlers = GlobalHandlers;
exports.globalHandlers = new GlobalHandlers();
/**
* A Workflow implementation using Node.js' built-in `vm` module.
*/
class BaseVMWorkflow {
runId;
context;
activator;
workflowModule;
unhandledRejection;
constructor(runId, context, activator, workflowModule) {
this.runId = runId;
this.context = context;
this.activator = activator;
this.workflowModule = workflowModule;
}
/**
* Send request to the Workflow runtime's worker-interface
*/
async getAndResetSinkCalls() {
return this.activator.getAndResetSinkCalls();
}
/**
* Send request to the Workflow runtime's worker-interface
*/
async activate(activation) {
try {
if (this.context === undefined)
throw new common_1.IllegalStateError('Workflow isolate context uninitialized');
activation = proto_1.coresdk.workflow_activation.WorkflowActivation.fromObject(activation);
if (!activation.jobs)
throw new TypeError('Expected workflow activation jobs to be defined');
// Queries are particular in many ways, and Core guarantees that a single activation will not
// contain both queries and other jobs. So let's handle them separately.
const [queries, nonQueries] = partition(activation.jobs, ({ queryWorkflow }) => queryWorkflow != null);
if (queries.length > 0) {
if (nonQueries.length > 0)
throw new TypeError('Got both queries and other jobs in a single activation');
return this.activateQueries(activation);
}
// Update the activator's state in preparation for a non-query activation.
// This is done early, so that we can then rely on the activator while processing the activation.
if (activation.timestamp == null)
throw new TypeError('Expected activation.timestamp to be set for non-query activation');
this.activator.now = (0, time_1.tsToMs)(activation.timestamp);
this.activator.mutateWorkflowInfo((info) => ({
...info,
historyLength: activation.historyLength,
// Exact truncation for multi-petabyte histories
// historySize === 0 means WFT was generated by pre-1.20.0 server, and the history size is unknown
historySize: activation.historySizeBytes?.toNumber() ?? 0,
continueAsNewSuggested: activation.continueAsNewSuggested ?? false,
currentBuildId: activation.deploymentVersionForCurrentTask?.buildId ?? '',
currentDeploymentVersion: (0, utils_1.convertDeploymentVersion)(activation.deploymentVersionForCurrentTask),
unsafe: {
...info.unsafe,
isReplaying: activation.isReplaying ?? false,
},
}));
this.activator.addKnownFlags(activation.availableInternalFlags ?? []);
// Initialization of the workflow must happen before anything else. Yet, keep the init job in
// place in the list as we'll use it as a marker to know when to start the workflow function.
const initWorkflowJob = activation.jobs.find((job) => job.initializeWorkflow != null)?.initializeWorkflow;
if (initWorkflowJob)
this.workflowModule.initialize(initWorkflowJob);
const hasSignals = activation.jobs.some(({ signalWorkflow }) => signalWorkflow != null);
const doSingleBatch = !hasSignals || this.activator.hasFlag(flags_1.SdkFlags.ProcessWorkflowActivationJobsAsSingleBatch);
const [patches, nonPatches] = partition(activation.jobs, ({ notifyHasPatch }) => notifyHasPatch != null);
for (const { notifyHasPatch } of patches) {
if (notifyHasPatch == null)
throw new TypeError('Expected notifyHasPatch to be set');
this.activator.notifyHasPatch(notifyHasPatch);
}
if (doSingleBatch) {
// updateRandomSeed requires the same special handling as patches (before anything else, and don't
// unblock conditions after each job). Unfortunately, prior to ProcessWorkflowActivationJobsAsSingleBatch,
// they were handled as regular jobs, making it unsafe to properly handle that job above, with patches.
const [updateRandomSeed, rest] = partition(nonPatches, ({ updateRandomSeed }) => updateRandomSeed != null);
if (updateRandomSeed.length > 0)
this.activator.updateRandomSeed(updateRandomSeed[updateRandomSeed.length - 1].updateRandomSeed);
this.workflowModule.activate(proto_1.coresdk.workflow_activation.WorkflowActivation.fromObject({ ...activation, jobs: rest }));
this.tryUnblockConditionsAndMicrotasks();
}
else {
const [signals, nonSignals] = partition(nonPatches,
// Move signals to a first batch; all the rest goes in a second batch.
({ signalWorkflow }) => signalWorkflow != null);
// Loop and invoke each batch, waiting for microtasks to complete after each batch.
let batchIndex = 0;
for (const jobs of [signals, nonSignals]) {
if (jobs.length === 0)
continue;
this.workflowModule.activate(proto_1.coresdk.workflow_activation.WorkflowActivation.fromObject({ ...activation, jobs }), batchIndex++);
this.tryUnblockConditionsAndMicrotasks();
}
}
const completion = this.workflowModule.concludeActivation();
// Give unhandledRejection handler a chance to be triggered.
await new Promise(setImmediate);
if (this.unhandledRejection)
throw this.unhandledRejection;
return completion;
}
catch (err) {
return {
runId: this.activator.info.runId,
// FIXME: Calling `activator.errorToFailure()` directly from outside the VM is unsafe, as it
// depends on the `failureConverter` and `payloadConverter`, which may be customized and
// therefore aren't guaranteed not to access `global` or to cause scheduling microtasks.
// Admitingly, the risk is very low, so we're leaving it as is for now.
failed: { failure: this.activator.errorToFailure(err) },
};
}
}
activateQueries(activation) {
this.activator.mutateWorkflowInfo((info) => ({
...info,
unsafe: {
...info.unsafe,
isReplaying: true,
},
}));
this.workflowModule.activate(activation);
return this.workflowModule.concludeActivation();
}
/**
* If called (by an external unhandledRejection handler), activations will fail with provided error.
*/
setUnhandledRejection(err) {
if (this.activator) {
// This is very unlikely to make a difference, as unhandled rejections should be reported
// on the next macro task of the outer execution context (i.e. not inside the VM), at which
// point we are done handling the workflow activation anyway. But just in case, copying the
// error to the activator will ensure that any attempt to make progress in the workflow
// VM will immediately fail.
this.activator.workflowTaskError = err;
}
this.unhandledRejection = err;
}
/**
* Call into the Workflow context to attempt to unblock any blocked conditions and microtasks.
*
* This is performed in a loop, going in and out of the VM, allowing microtasks to be processed
* between each iteration of the outer loop, until there are no more conditions to unblock.
*/
tryUnblockConditionsAndMicrotasks() {
for (;;) {
const numUnblocked = this.workflowModule.tryUnblockConditions();
if (numUnblocked === 0)
break;
}
}
}
exports.BaseVMWorkflow = BaseVMWorkflow;
function partition(arr, predicate) {
const truthy = Array();
const falsy = Array();
arr.forEach((v) => (predicate(v) ? truthy : falsy).push(v));
return [truthy, falsy];
}
//# sourceMappingURL=vm-shared.js.map
;