UNPKG

@temporalio/worker

Version:
428 lines 21.4 kB
"use strict"; 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