UNPKG

durabull

Version:

A durable workflow engine built on top of BullMQ and Redis

160 lines (159 loc) 7.08 kB
"use strict"; /** * ActivityStub - interface for executing activities from workflows */ Object.defineProperty(exports, "__esModule", { value: true }); exports.ActivityStub = exports.ActivityPromise = void 0; const queues_1 = require("./queues"); const WorkflowStub_1 = require("./WorkflowStub"); const isPromiseLike = (value) => { return typeof value === 'object' && value !== null && 'then' in value && typeof value.then === 'function'; }; /** * Promise-like wrapper around activity execution */ class ActivityPromise { constructor(promise) { this.promise = promise; } then(onfulfilled, onrejected) { return this.promise.then(onfulfilled, onrejected); } } exports.ActivityPromise = ActivityPromise; class ActivityStub { /** * Execute an activity - queues to BullMQ in durable mode, executes inline in test mode */ static make(activityClassOrName, ...argsWithOptions) { // Parse args and options const lastArg = argsWithOptions[argsWithOptions.length - 1]; let options; let args; // Check for explicit options wrapper const isWrappedOptions = lastArg && typeof lastArg === 'object' && !Array.isArray(lastArg) && Object.prototype.toString.call(lastArg) === '[object Object]' && Object.prototype.hasOwnProperty.call(lastArg, '__options'); if (isWrappedOptions) { options = lastArg.__options; args = argsWithOptions.slice(0, -1); } else { args = argsWithOptions; } // CRITICAL FIX: Capture activity name and defaults immediately as const // DO NOT use intermediate let variables - they get overwritten in rapid generator calls let defaultTries; let defaultBackoff; if (typeof activityClassOrName !== 'string') { try { const instance = new activityClassOrName(); defaultTries = instance.tries; // We can't easily get the backoff method result without calling it, // but backoff() is a method returning number[]. if (instance.backoff) { defaultBackoff = instance.backoff(); } } catch (e) { // Ignore instantiation errors } } // Generate activity ID synchronously to ensure deterministic ordering // This must happen before the async function executes to capture the correct activityCursor const activityId = options?.activityId || WorkflowStub_1.WorkflowStub._generateActivityId(); // CRITICAL FIX: Capture values immediately as const to prevent closure bugs // Each call to make() must capture its own immutable values before any async boundary const finalActivityName = typeof activityClassOrName === 'string' ? activityClassOrName : activityClassOrName.name; const finalArgs = Array.isArray(args) ? [...args] : args; const finalDefaultTries = defaultTries; const finalDefaultBackoff = defaultBackoff; // Queue activity and return promise that will be resolved by workflow worker via history replay const promise = (async () => { const workflowContext = WorkflowStub_1.WorkflowStub._getContext(); if (!workflowContext) { throw new Error('ActivityStub must be called within a workflow context'); } const queues = (0, queues_1.getQueues)(); const workflowId = workflowContext.workflowId; // CRITICAL FIX: Use history from context instead of reading from storage // This ensures we're checking against the same history that ReplayEngine is using const history = workflowContext.history; // Check if this activity already completed (replay) if (history) { const existingEvent = history.events.find((e) => e.type === 'activity' && e.id === activityId); if (existingEvent && existingEvent.type === 'activity') { // Activity already completed during previous execution - return cached result if (existingEvent.error) { throw new Error(existingEvent.error.message || 'Activity failed'); } return existingEvent.result; } } // Activity not in history - need to queue it // Build retry options from activity metadata and per-invocation overrides const retryOptions = {}; const tries = options?.tries ?? finalDefaultTries; if (tries !== undefined) retryOptions.tries = tries; if (options?.timeout !== undefined) retryOptions.timeout = options.timeout; const backoff = options?.backoff ?? finalDefaultBackoff; if (backoff) retryOptions.backoff = backoff; // Map to BullMQ options // tries: 0 means retry forever (MAX_INT) const attempts = (tries === 0) ? Number.MAX_SAFE_INTEGER : (tries || 1); // Queue the activity job (only on first execution, not replay) // Use activityId as BullMQ jobId to prevent duplicate jobs await queues.activity.add('execute', { workflowId, activityClass: finalActivityName, activityId, args: finalArgs, retryOptions: Object.keys(retryOptions).length > 0 ? retryOptions : undefined, }, { jobId: `${workflowId}-${activityId}`, // Ensures uniqueness per workflow attempts, backoff: { type: 'custom', // We use the custom strategy defined in worker } }); // Throw WorkflowWaitError to suspend execution - worker will resume when activity completes throw new WorkflowStub_1.WorkflowWaitError(`Waiting for activity ${activityId}`); })(); return new ActivityPromise(promise); } /** * Execute multiple activities in parallel */ static all(promises) { const allPromise = Promise.all(promises.map(p => p)); return new ActivityPromise(allPromise); } /** * Execute an async generator helper (mini sub-workflow) */ static async(genFn) { const promise = (async () => { const gen = genFn(); let result = await gen.next(); while (!result.done) { if (isPromiseLike(result.value)) { const resolved = await result.value; result = await gen.next(resolved); continue; } result = await gen.next(result.value); } return result.value; })(); return new ActivityPromise(promise); } } exports.ActivityStub = ActivityStub;