torotask
Version:
Task queue processing in NodeJS based on BullMQ and Redis
455 lines • 24.3 kB
JavaScript
import { isArray, isPlainObject } from 'lodash-es';
import ms from 'ms';
import { TaskJob } from './job.js';
import { DelayedError, WaitingChildrenError } from './step-errors.js';
import { getDateTime } from './utils/get-datetime.js';
import { isControlError } from './utils/is-control-error.js';
import { deserializeError, serializeError } from './utils/serialize-error.js';
export class StepExecutor {
job;
parentTask;
logger;
stepCounts = new Map();
client;
constructor(job, parentTask) {
this.job = job;
this.parentTask = parentTask;
this.logger = job.logger || console;
this.client = parentTask.group.client;
if (typeof this.job.state !== 'object' || this.job.state === null) {
this.job.state = {};
}
this.job.state.stepState = this.job.state.stepState || {};
}
async persistState() {
const stateToUpdate = {
stepState: this.job.state.stepState,
};
await this.job.updateState(stateToUpdate);
}
/**
* Process step result before storing it in the job state.
* This method automatically prepares TaskJob instances ready for serialization.
* Recursively processes arrays and objects to find and serialize any TaskJob instances.
*
* @param result The result from a step's core logic
* @param stepKind The kind of step that produced this result
* @returns A processed version of the result suitable for storage
*/
async memoizeStepResult(result, stepKind) {
// Base case: null or undefined
if (result === null || result === undefined) {
return result;
}
// Handle arrays of TaskJob instances (bulk operations) differently
if (isArray(result) && result.length > 0 && result[0] instanceof TaskJob) {
const jobs = result;
// If array is large (e.g., more than 10 elements), use compact representation
if (jobs.length > 10) {
this.logger.debug({ jobCount: jobs.length, stepKind }, `Using compact bulk job reference for ${jobs.length} jobs`);
// Extract common queue name (assuming all jobs are in the same queue)
const queueName = jobs[0].queueName;
// Create compact reference
const bulkRef = {
_isBulkJobsReference: true,
count: jobs.length,
queueName,
timestamp: Date.now(),
// Store only first few job IDs as a sample
sampleJobIds: jobs.slice(0, 3).map((j) => j.id),
};
return bulkRef;
}
// For smaller arrays, continue with normal serialization
this.logger.debug({ stepKind, arrayLength: result.length }, `Processing array of TaskJob instances`);
return Promise.all(result.map((item) => this.memoizeStepResult(item, stepKind)));
}
// Handle TaskJob instances by simplifying them
if (result instanceof TaskJob) {
const complexJob = result;
this.logger.debug({ jobId: result.id, stepKind }, `Automatically processing TaskJob instance for serialization`);
const simpleJob = {
jobId: complexJob.id,
queue: complexJob.queueName,
timestamp: complexJob.timestamp,
_isMemoizedTaskJob: true,
};
return simpleJob;
}
// Handle arrays by recursively processing each element
if (isArray(result)) {
this.logger.debug({ stepKind, arrayLength: result.length }, `Processing array for potential TaskJob instances`);
return Promise.all(result.map((item) => this.memoizeStepResult(item, stepKind)));
}
// Handle plain objects by recursively processing each property
if (isPlainObject(result)) {
this.logger.debug({ stepKind, objectKeys: Object.keys(result).length }, `Processing object for potential TaskJob instances`);
const processedObject = {};
for (const [key, value] of Object.entries(result)) {
processedObject[key] = await this.memoizeStepResult(value, stepKind);
}
return processedObject;
}
// For other types (primitives, functions, etc.), just return as is
return result;
}
/**
* Checks if the memoized data is a job that needs to be reconstructed.
* Recursively processes arrays and objects to find and reconstruct any serialized jobs.
*
* @param memoizedData The data from the memoized result
* @param userStepId The ID of the step for logging purposes
* @returns The original data or a reconstructed job
*/
async reconstructJobIfNeeded(memoizedData, userStepId) {
// Base case: null or undefined
if (memoizedData === null || memoizedData === undefined) {
return memoizedData;
}
// Handle bulk job references
if (memoizedData && memoizedData._isBulkJobsReference) {
this.logger.debug({ count: memoizedData.count, queue: memoizedData.queueName, userStepId }, `Detected bulk job reference for step '${userStepId}' with ${memoizedData.count} jobs`);
const bulkRef = memoizedData;
// Try to find child jobs by parent relationship instead of reconstructing each one
try {
if (this.client && this.job.id) {
// Get all child jobs from the queue that have this job as parent
const childJobs = await this.client.getChildJobs(bulkRef.queueName, this.job.id,
// Optionally use timestamp for filtering if needed
bulkRef.timestamp);
if (childJobs.length === bulkRef.count) {
this.logger.debug({ count: childJobs.length, userStepId }, `Successfully retrieved all ${childJobs.length} child jobs`);
return childJobs;
}
else {
this.logger.warn({ expectedCount: bulkRef.count, actualCount: childJobs.length, userStepId }, `Found different number of child jobs than expected for step '${userStepId}'`);
return childJobs;
}
}
else {
throw new Error(`Cannot retrieve bulk jobs: client or job ID not available`);
}
}
catch (error) {
this.logger.error({ error: error?.message, count: bulkRef.count, userStepId }, `Error retrieving bulk jobs for step '${userStepId}'`);
throw deserializeError(error);
}
}
// Check if this is a memoized job by looking for our marker
if (memoizedData && memoizedData._isMemoizedTaskJob) {
this.logger.debug({ jobId: memoizedData.jobId, userStepId }, `Detected memoized job data for step '${userStepId}'`);
const jobData = memoizedData;
// Try to reconstruct the job from the queue if possible
try {
// If we have a parent task and queue info, we could try to get the job from the queue
if (this.client && jobData.queue) {
const job = await this.client.getJobById(jobData.queue, jobData.jobId);
return job;
}
else {
throw new Error(`Cannot reconstruct job: client or queue not available.`);
}
}
catch (error) {
this.logger.error({ error: error?.message, jobId: memoizedData.jobId, userStepId }, `Error during job reconstruction attempt`);
throw deserializeError(error);
}
}
// Handle arrays by recursively processing each element
if (isArray(memoizedData)) {
this.logger.debug({ userStepId, arrayLength: memoizedData.length }, `Processing array for potential serialized jobs`);
const result = await Promise.all(memoizedData.map((item) => this.reconstructJobIfNeeded(item, userStepId)));
return result;
}
// Handle plain objects by recursively processing each property
if (isPlainObject(memoizedData)) {
this.logger.debug({ userStepId, objectKeys: Object.keys(memoizedData).length }, `Processing object for potential serialized jobs`);
const result = {};
for (const [key, value] of Object.entries(memoizedData)) {
result[key] = await this.reconstructJobIfNeeded(value, userStepId);
}
return result;
}
// Not a job, return original data
return memoizedData;
}
/**
* Centralized method to execute a step, handling memoization, state persistence, and errors.
* @param userStepId User-defined ID for the step.
* @param stepKind A string identifier for the kind of step (e.g., 'do', 'sleep').
* @param coreLogic The async function that performs the actual work of the step.
* If it initiates a pending state (e.g., sleep, wait), it should:
* 1. Update \`this.job.state.stepState\` with the new status (e.g., 'sleeping').
* 2. Call \`await this.persistState()\`.
* 3. Throw a \`WorkflowPendingError\` (e.g., \`DelayedError\`).
* If it completes successfully, it returns the result.
* If it fails with an unexpected error, it throws that error.
* @param handleMemoizedState Optional handler for memoized states that are not 'completed' or 'errored'.
* Used for re-evaluating pending states (e.g., checking if sleep duration has passed).
* If it handles the state, it should return \`{ processed: true, ... }\`.
* If the step completes, it must update state and persist.
* @param memoizeStepResult Optional function to customize how the step result is serialized before storage.
* If not provided, the default memoizeStepResult method will be used.
*/
async _executeStep(userStepId, stepKind, coreLogic, handleMemoizedState, memoizeStepResult // Optional custom memoizer
) {
const currentCount = this.stepCounts.get(userStepId) || 0;
const internalStepId = `${userStepId}_${currentCount}`;
this.stepCounts.set(userStepId, currentCount + 1);
const memoizedResult = this.job.state.stepState[internalStepId];
if (memoizedResult) {
if (memoizedResult.status === 'completed') {
this.logger.debug({ internalStepId, data: memoizedResult.data, stepKind }, `Step '${userStepId}' (id: ${internalStepId}) already completed, returning memoized data.`);
// Check if the memoized result is a job that needs reconstruction
const result = await this.reconstructJobIfNeeded(memoizedResult.data, userStepId);
return result;
}
else if (memoizedResult.status === 'errored') {
this.logger.warn({ internalStepId, error: memoizedResult.error, stepKind }, `Step '${userStepId}' (id: ${internalStepId}) previously errored, re-throwing.`);
throw deserializeError(memoizedResult.error);
}
else if (handleMemoizedState) {
const intermediateOutcome = await handleMemoizedState(memoizedResult, internalStepId);
if (intermediateOutcome.processed) {
if (intermediateOutcome.errorToThrow) {
throw intermediateOutcome.errorToThrow;
}
return intermediateOutcome.result;
}
this.logger.warn({ internalStepId, memoizedResult, stepKind }, `Memoized step '${userStepId}' (id: ${internalStepId}) with status '${memoizedResult.status}' not fully handled by intermediate handler, proceeding to core logic.`);
}
else {
this.logger.warn({ internalStepId, memoizedResult, stepKind }, `Memoized step '${userStepId}' (id: ${internalStepId}) with unhandled status '${memoizedResult.status}', proceeding to core logic.`);
}
}
try {
const result = await coreLogic(internalStepId);
// Process the result for storage in the state - use custom serializer if provided, otherwise use default
const processedResult = memoizeStepResult
? await memoizeStepResult(result, stepKind)
: await this.memoizeStepResult(result, stepKind);
this.job.state.stepState[internalStepId] = {
status: 'completed',
data: processedResult,
};
await this.persistState();
return result;
}
catch (error) {
if (isControlError(error) && error.message.includes(internalStepId)) {
throw error;
}
this.logger.debug({ internalStepId, stepKind, err: error }, `Core logic for step '${userStepId}' (id: ${internalStepId}) threw an unexpected error.`);
this.job.state.stepState[internalStepId] = {
status: 'errored',
error: serializeError(error),
};
await this.persistState();
throw error;
}
}
async do(userStepId, handler) {
return this._executeStep(userStepId, 'do', async (_internalStepId) => {
return handler();
});
}
async sleep(userStepId, duration) {
return this._executeStep(userStepId, 'sleep', async (internalStepId) => {
const durationMs = typeof duration === 'number' ? duration : ms(duration);
const newSleepUntil = Date.now() + durationMs;
this.job.state.stepState[internalStepId] = {
status: 'sleeping',
sleepUntil: newSleepUntil,
};
await this.persistState();
await this.job.moveToDelayed(newSleepUntil, this.job.token);
throw new DelayedError(`Step "${internalStepId}" is sleeping.`);
}, async (memoizedResult, internalStepId) => {
if (memoizedResult.status === 'sleeping') {
const sleepUntil = memoizedResult.sleepUntil;
if (Date.now() >= sleepUntil) {
this.job.state.stepState[internalStepId] = { status: 'completed' };
await this.persistState();
return { processed: true, result: undefined };
}
else {
await this.job.moveToDelayed(sleepUntil, this.job.token);
return {
processed: true,
errorToThrow: new DelayedError(`Step "${internalStepId}" is sleeping.`),
};
}
}
return { processed: false };
});
}
async sleepUntil(userStepId, datetime) {
return this._executeStep(userStepId, 'sleepUntil', async (internalStepId) => {
this.logger.debug({ internalStepId, userStepId, datetime }, `sleepUntil called for step '${userStepId}' (id: ${internalStepId}).`);
const timestampMs = getDateTime(datetime);
if (timestampMs <= Date.now()) {
this.logger.debug({ internalStepId, userStepId, timestampMs }, `Timestamp for sleepUntil step '${userStepId}' (id: ${internalStepId}) is in the past. Completing immediately.`);
this.job.state.stepState[internalStepId] = { status: 'completed' };
await this.persistState();
return;
}
this.job.state.stepState[internalStepId] = { status: 'sleeping', sleepUntil: timestampMs };
await this.persistState();
await this.job.moveToDelayed(timestampMs, this.job.token);
throw new DelayedError(`Step "${internalStepId}" is sleeping until specific time.`);
}, async (memoizedResult, internalStepId) => {
if (memoizedResult.status === 'sleeping') {
const sleepUntilTime = memoizedResult.sleepUntil;
if (Date.now() >= sleepUntilTime) {
this.job.state.stepState[internalStepId] = { status: 'completed' };
await this.persistState();
return { processed: true, result: undefined };
}
else {
await this.job.moveToDelayed(sleepUntilTime, this.job.token);
return {
processed: true,
errorToThrow: new DelayedError(`Step "${internalStepId}" is sleeping until specific time.`),
};
}
}
return { processed: false };
});
}
async runGroupTask(userStepId, taskName, payload, options) {
return this._executeStep(userStepId, 'runGroupTask', async (_internalStepId) => {
const taskKey = taskName.toString();
const task = this.parentTask?.group.tasks[taskKey];
if (!task) {
throw new Error(`Task '${taskKey}' not found in this Task group.`);
}
return (await task.run(payload, { ...options, parent: this.job }));
});
}
async runGroupTaskAndWait(userStepId, taskName, payload, options) {
return this._executeStep(userStepId, 'runGroupTaskAndWait', async (_internalStepId) => {
const taskKey = taskName.toString();
const task = this.parentTask?.group.tasks[taskKey];
if (!task) {
throw new Error(`Task '${taskKey}' not found in this Task group.`);
}
return await task.runAndWait(payload, { ...options, parent: this.job });
});
}
async runTask(userStepId, groupName, taskName, payload, options) {
return this._executeStep(userStepId, 'runTask', async (_internalStepId) => {
if (!this.parentTask) {
throw new Error('Cannot start task: parentTask is not available.');
}
const client = this.parentTask.group.client;
const groupKey = groupName.toString();
const taskGroup = client.taskGroups[groupKey];
if (!taskGroup) {
throw new Error(`Task group '${groupKey}' not found.`);
}
const taskKey = taskName.toString();
const task = taskGroup.tasks[taskKey];
if (!task) {
throw new Error(`Task '${taskKey}' not found in group '${groupKey}'.`);
}
const taskJob = (await task.run(payload, { ...options, parent: this.job }));
return taskJob;
});
}
async runTaskAndWait(userStepId, groupName, taskName, payload, options) {
return this._executeStep(userStepId, 'runTaskAndWait', async (_internalStepId) => {
if (!this.parentTask) {
throw new Error('Cannot run task: parentTask is not available.');
}
const client = this.parentTask.group.client;
const groupKey = groupName.toString();
const taskGroup = client.taskGroups[groupKey];
if (!taskGroup) {
throw new Error(`Task group '${groupKey}' not found.`);
}
const taskKey = taskName.toString();
const task = taskGroup.tasks[taskKey];
if (!task) {
throw new Error(`Task '${taskKey}' not found in group '${groupKey}'.`);
}
return (await task.runAndWait(payload, { ...options, parent: this.job }));
});
}
async runTasks(userStepId, groupName, taskName, tasks, options) {
return this._executeStep(userStepId, 'runTasks', async (_internalStepId) => {
if (!this.parentTask) {
throw new Error('Cannot start tasks: parentTask is not available.');
}
const client = this.parentTask.group.client;
const groupKey = groupName.toString();
const taskGroup = client.taskGroups[groupKey];
if (!taskGroup) {
throw new Error(`Task group '${groupKey}' not found.`);
}
const taskKey = taskName.toString();
const task = taskGroup.tasks[taskKey];
if (!task) {
throw new Error(`Task '${taskKey}' not found in group '${groupKey}'.`);
}
const taskJobs = (await task.runMany(tasks, { ...options, parent: this.job }));
return taskJobs;
});
}
async runFlow(userStepId, task, options) {
return this._executeStep(userStepId, 'runFlow', async (_internalStepId) => {
const result = this.client.runFlow(task, { ...options, parent: this.job });
return result;
});
}
async runFlows(userStepId, tasks, options) {
return this._executeStep(userStepId, 'runFlow', async (_internalStepId) => {
const result = this.client.runFlows(tasks, { ...options, parent: this.job });
return result;
});
}
async waitForChildTasks(userStepId) {
return this._executeStep(userStepId, 'waitForChildTasks', async (internalStepId) => {
this.logger.debug({ internalStepId, userStepId }, `waitForChildTasks called for step '${userStepId}' (id: ${internalStepId}).`);
this.job.state.stepState[internalStepId] = { status: 'waiting_for_children' };
await this.persistState();
const token = this.job.token || '';
const shouldWait = await this.job.moveToWaitingChildren(token);
if (shouldWait) {
this.logger.debug({ internalStepId, userStepId }, `Step '${userStepId}' (id: ${internalStepId}) successfully moved to waiting for children, throwing WaitingChildrenError.`);
// Create and throw the error - BullMQ will recognize it
throw new WaitingChildrenError(`Step "${internalStepId}" is waiting for child tasks.`);
}
else {
this.logger.debug({ internalStepId, userStepId }, `Step '${userStepId}' (id: ${internalStepId}) does not need to wait for children. Completing step.`);
return [];
}
}, async (memoizedResult, internalStepId) => {
if (memoizedResult.status === 'waiting_for_children') {
this.logger.debug({ internalStepId, userStepId, tasks: memoizedResult.childTaskIds }, `Handling intermediate 'waiting_for_children' state for step '${userStepId}' (id: ${internalStepId}). Re-checking.`);
const token = this.job.token || '';
const shouldStillWait = await this.job.moveToWaitingChildren(token);
if (shouldStillWait) {
this.logger.debug({ internalStepId, userStepId }, `Still waiting for children for step '${userStepId}' (id: ${internalStepId}). Re-throwing WaitingChildrenError.`);
return {
processed: true,
errorToThrow: new WaitingChildrenError(`Step "${internalStepId}" is waiting for child tasks.`),
};
}
else {
this.logger.debug({ internalStepId, userStepId }, `Children for step '${userStepId}' (id: ${internalStepId}) are now complete. Marking step as completed.`);
this.job.state.stepState[internalStepId] = { status: 'completed', data: [] };
await this.persistState();
return { processed: true, result: [] };
}
}
return { processed: false };
});
}
async sendEvent(userStepId, eventName, eventData) {
return this._executeStep(userStepId, 'sendEvent', async () => {
await this.job.taskClient?.sendEvent(eventName, eventData);
return;
});
}
}
//# sourceMappingURL=step-executor.js.map