durabull
Version:
A durable workflow engine built on top of BullMQ and Redis
160 lines (159 loc) • 7.08 kB
JavaScript
;
/**
* 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;