UNPKG

durabull

Version:

A durable workflow engine built on top of BullMQ and Redis

527 lines (526 loc) 18.8 kB
"use strict"; /* eslint-disable @typescript-eslint/no-explicit-any */ /** * WorkflowStub - interface for controlling and executing workflows (Durable Mode Only) */ Object.defineProperty(exports, "__esModule", { value: true }); exports.WorkflowStub = exports.WorkflowHandle = exports.WorkflowContinueAsNewError = exports.WorkflowWaitError = void 0; const decorators_1 = require("./decorators"); const storage_1 = require("./runtime/storage"); const queues_1 = require("./queues"); const ids_1 = require("./runtime/ids"); const global_1 = require("./config/global"); const logger_1 = require("./runtime/logger"); const replayer_1 = require("./runtime/replayer"); const errors_1 = require("./errors"); const context_1 = require("./runtime/context"); var errors_2 = require("./errors"); Object.defineProperty(exports, "WorkflowWaitError", { enumerable: true, get: function () { return errors_2.WorkflowWaitError; } }); Object.defineProperty(exports, "WorkflowContinueAsNewError", { enumerable: true, get: function () { return errors_2.WorkflowContinueAsNewError; } }); /** * WorkflowHandle - interface for interacting with a workflow instance */ class WorkflowHandle { constructor(workflowId, workflowName) { this.workflowId = workflowId; this.workflowName = workflowName; } id() { return this.workflowId; } async start(...args) { const storage = (0, storage_1.getStorage)(); const queues = (0, queues_1.getQueues)(); const instance = global_1.Durabull.getActive(); if (!instance) { throw new Error('No active Durabull instance'); } const config = instance.getConfig(); const record = { id: this.workflowId, class: this.workflowName, status: 'pending', args, createdAt: Date.now(), updatedAt: Date.now(), }; await storage.writeRecord(record); await storage.writeHistory(this.workflowId, { events: [], cursor: 0 }); // Call onStart lifecycle hook if (config.lifecycleHooks?.workflow?.onStart) { try { await config.lifecycleHooks.workflow.onStart(this.workflowId, this.workflowName, args); } catch (error) { const logger = (0, logger_1.createLoggerFromConfig)(config.logger); logger.error('Workflow onStart hook failed', error); } } await queues.workflow.add('start', { workflowId: this.workflowId, workflowName: this.workflowName, args, isResume: false, }); } async status() { const storage = (0, storage_1.getStorage)(); const record = await storage.readRecord(this.workflowId); if (!record) { return 'pending'; } return record.status; } async output() { const storage = (0, storage_1.getStorage)(); const record = await storage.readRecord(this.workflowId); if (!record) { throw new Error(`Workflow ${this.workflowId} not found`); } if (record.status === 'failed') { throw new Error(record.error?.message || 'Workflow failed'); } if (record.status !== 'completed') { throw new Error(`Workflow is ${record.status}, not completed`); } return record.output; } /** * Get query method proxy - queries access workflow state from storage */ query(WorkflowClass) { const queryMethods = (0, decorators_1.getQueryMethods)(WorkflowClass); const proxy = {}; for (const methodName of queryMethods) { proxy[methodName] = async (...args) => { try { // Replay workflow to get current state const { workflow } = await (0, replayer_1.replayWorkflow)(this.workflowId, WorkflowClass); // Execute query on the replayed instance return workflow[methodName](...args); } catch (error) { const instance = global_1.Durabull.getActive(); const logger = (0, logger_1.createLoggerFromConfig)(instance?.getConfig().logger); logger.error('Query execution failed', error); throw error; } }; } return proxy; } } exports.WorkflowHandle = WorkflowHandle; /** * WorkflowStub - Static methods for workflow control */ class WorkflowStub { /** * Create a new workflow handle */ static async make(workflowClassOrName, options) { const instance = global_1.Durabull.getActive(); if (!instance) { throw new Error('No active Durabull instance. Create an instance and call setActive()'); } let workflowName; if (typeof workflowClassOrName === 'string') { workflowName = workflowClassOrName; const resolved = instance.resolveWorkflow(workflowName); if (!resolved) { throw new Error(`Workflow "${workflowName}" not registered`); } } else { workflowName = workflowClassOrName.name; } const id = options?.id || (0, ids_1.generateWorkflowId)(); return new WorkflowHandle(id, workflowName); } /** * Load an existing workflow by ID */ static async load(id) { const storage = (0, storage_1.getStorage)(); const record = await storage.readRecord(id); if (!record) { throw new Error(`Workflow ${id} not found`); } return new WorkflowHandle(id, record.class); } /** * Send a signal to a workflow */ static async sendSignal(workflowId, signalName, payload) { const storage = (0, storage_1.getStorage)(); const queues = (0, queues_1.getQueues)(); await storage.pushSignal(workflowId, { name: signalName, payload, ts: Date.now(), }); await queues.workflow.add('resume', { workflowId, isResume: true, }); } /** * Get current time (replay-safe for workflows) */ static now() { const ctx = (0, context_1.getWorkflowContext)(); if (!ctx) { return new Date(); } const ts = (0, context_1.getVirtualTimestamp)(ctx.workflowId); return new Date(ts); } /** * Sleep for a duration (workflow timer) */ static async timer(durationSeconds) { const seconds = typeof durationSeconds === 'string' ? parseInt(durationSeconds, 10) : durationSeconds; const ctx = (0, context_1.getWorkflowContext)(); if (!ctx) { await new Promise(resolve => setTimeout(resolve, seconds * 1000)); return; } const storage = (0, storage_1.getStorage)(); const queues = (0, queues_1.getQueues)(); const timerId = WorkflowStub._generateTimerId(); const history = ctx.history || await storage.readHistory(ctx.workflowId); if (history && !ctx.eventIndex) { ctx.eventIndex = new Map(); for (const event of history.events) { if (event.id) { ctx.eventIndex.set(`${event.type}:${event.id}`, event); } } } if (history) { const firedEvent = ctx.eventIndex ? ctx.eventIndex.get(`timer-fired:${timerId}`) : history.events.find((e) => e.type === 'timer-fired' && e.id === timerId); if (firedEvent) { return; } } const delayMs = Math.max(0, seconds * 1000); if (delayMs === 0) { return; } const startedEvent = ctx.eventIndex ? ctx.eventIndex.get(`timer-started:${timerId}`) : history?.events.find((e) => e.type === 'timer-started' && e.id === timerId); if (!startedEvent) { const event = { type: 'timer-started', id: timerId, ts: Date.now(), delay: seconds, }; await storage.appendEvent(ctx.workflowId, event); if (ctx.eventIndex) { ctx.eventIndex.set(`timer-started:${timerId}`, event); } const record = await storage.readRecord(ctx.workflowId); if (record) { record.status = 'waiting'; record.waiting = { type: 'await', resumeAt: Date.now() + seconds * 1000, }; record.updatedAt = Date.now(); await storage.writeRecord(record); } await queues.workflow.add('resume', { workflowId: ctx.workflowId, isResume: true, timerId, // Pass timerId to worker }, { delay: seconds * 1000, }); } throw new errors_1.WorkflowWaitError(`Timer ${timerId} waiting ${seconds}s`); } /** * Wait for a condition to become true */ static async await(predicate) { if (predicate()) { return; } const ctx = (0, context_1.getWorkflowContext)(); if (!ctx) { // Not in workflow context - poll while (!predicate()) { await new Promise(resolve => setTimeout(resolve, 100)); } return; } const storage = (0, storage_1.getStorage)(); const queues = (0, queues_1.getQueues)(); // Update workflow to waiting status const record = await storage.readRecord(ctx.workflowId); if (record) { record.status = 'waiting'; record.waiting = { type: 'await', resumeAt: Date.now() + 100, // Check again in 100ms }; record.updatedAt = Date.now(); await storage.writeRecord(record); } // Schedule resume await queues.workflow.add('resume', { workflowId: ctx.workflowId, isResume: true, }, { delay: 100, }); throw new errors_1.WorkflowWaitError('Awaiting condition'); } /** * Wait for condition with timeout */ static async awaitWithTimeout(durationSeconds, predicate) { if (predicate()) { return true; } const seconds = typeof durationSeconds === 'string' ? parseInt(durationSeconds, 10) : durationSeconds; const ctx = (0, context_1.getWorkflowContext)(); if (!ctx) { // Not in workflow - use regular timeout const start = Date.now(); while (!predicate()) { if (Date.now() - start >= seconds * 1000) { return false; } await new Promise(resolve => setTimeout(resolve, 100)); } return true; } const storage = (0, storage_1.getStorage)(); const queues = (0, queues_1.getQueues)(); const record = await storage.readRecord(ctx.workflowId); if (!record) { return false; } // Check if we've exceeded deadline const existingDeadline = record.waiting?.type === 'awaitWithTimeout' ? record.waiting.deadline : undefined; const deadline = existingDeadline ?? Date.now() + seconds * 1000; if (Date.now() >= deadline) { record.waiting = undefined; await storage.writeRecord(record); return false; } // Update to waiting record.status = 'waiting'; record.waiting = { type: 'awaitWithTimeout', resumeAt: Date.now() + 100, deadline, }; record.updatedAt = Date.now(); await storage.writeRecord(record); // Schedule resume await queues.workflow.add('resume', { workflowId: ctx.workflowId, isResume: true, }, { delay: 100, }); throw new errors_1.WorkflowWaitError('Awaiting with timeout'); } /** * Continue as new workflow */ static async continueAsNew(...args) { const ctx = (0, context_1.getWorkflowContext)(); if (!ctx) { throw new Error('continueAsNew can only be called from within a workflow'); } const storage = (0, storage_1.getStorage)(); const queues = (0, queues_1.getQueues)(); const newWorkflowId = (0, ids_1.generateWorkflowId)(); const now = Date.now(); // Create new workflow record const newRecord = { id: newWorkflowId, class: ctx.record.class, status: 'pending', args, createdAt: now, updatedAt: now, continuedFrom: ctx.workflowId, }; await storage.writeRecord(newRecord); await storage.writeHistory(newWorkflowId, { events: [], cursor: 0 }); // Update current workflow const currentRecord = await storage.readRecord(ctx.workflowId); if (currentRecord) { currentRecord.status = 'continued'; currentRecord.continuedTo = newWorkflowId; currentRecord.waiting = undefined; currentRecord.updatedAt = now; await storage.writeRecord(currentRecord); } // Queue new workflow await queues.workflow.add('start', { workflowId: newWorkflowId, workflowName: ctx.record.class, args, isResume: false, }); throw new errors_1.WorkflowContinueAsNewError(newWorkflowId); } /** * Execute side effect (non-deterministic code) */ static async sideEffect(fn) { const ctx = (0, context_1.getWorkflowContext)(); if (!ctx) { // Not in workflow - just execute return await fn(); } const storage = (0, storage_1.getStorage)(); const sideEffectId = WorkflowStub._generateSideEffectId(); const history = ctx.history || await storage.readHistory(ctx.workflowId); // Check if side effect already executed (replay) if (history) { const existingEffect = history.events.find((e) => e.type === 'sideEffect' && e.id === sideEffectId); if (existingEffect && existingEffect.type === 'sideEffect') { return existingEffect.value; } } // Execute side effect const result = await fn(); // Record result await storage.appendEvent(ctx.workflowId, { type: 'sideEffect', id: sideEffectId, ts: Date.now(), value: result, }); return result; } /** * Execute child workflow */ static async child(workflowClassOrName, ...args) { const ctx = (0, context_1.getWorkflowContext)(); if (!ctx) { throw new Error('child workflows can only be called from within a workflow'); } const storage = (0, storage_1.getStorage)(); const queues = (0, queues_1.getQueues)(); const childId = WorkflowStub._generateChildWorkflowId(); const history = ctx.history || await storage.readHistory(ctx.workflowId); // Check if child already completed (replay) if (history) { const existingChild = history.events.find((e) => e.type === 'child' && e.id === childId); if (existingChild && existingChild.type === 'child') { if (existingChild.error) { throw new Error(existingChild.error.message || 'Child workflow failed'); } return existingChild.result; } } // Determine workflow name let workflowName; if (typeof workflowClassOrName === 'string') { workflowName = workflowClassOrName; } else { workflowName = workflowClassOrName.name; } // Create and start child workflow const childRecord = { id: childId, class: workflowName, status: 'pending', args, createdAt: Date.now(), updatedAt: Date.now(), }; await storage.writeRecord(childRecord); await storage.writeHistory(childId, { events: [], cursor: 0 }); await storage.addChild(ctx.workflowId, childId); // Queue child workflow await queues.workflow.add('start', { workflowId: childId, workflowName, args, isResume: false, }); // Wait for child to complete throw new errors_1.WorkflowWaitError(`Waiting for child workflow ${childId}`); } /** * Get workflow execution context (internal use) */ static _getContext() { return (0, context_1.getWorkflowContext)() || null; } /** * Generate a deterministic ID for a side effect */ static _generateSideEffectId() { const ctx = (0, context_1.getWorkflowContext)(); if (!ctx) { return (0, ids_1.generateSideEffectId)(); } const id = `se-${ctx.sideEffectCursor}`; ctx.sideEffectCursor++; return id; } /** * Generate a deterministic ID for a child workflow */ static _generateChildWorkflowId() { const ctx = (0, context_1.getWorkflowContext)(); if (!ctx) { return (0, ids_1.generateWorkflowId)(); } const id = `child-${ctx.childWorkflowCursor}`; ctx.childWorkflowCursor++; return id; } /** * Generate a deterministic ID for an activity */ static _generateActivityId() { const ctx = (0, context_1.getWorkflowContext)(); if (!ctx) { return (0, ids_1.generateActivityId)(); } const id = `act-${ctx.activityCursor}`; ctx.activityCursor++; return id; } /** * Generate a deterministic ID for a timer */ static _generateTimerId() { const ctx = (0, context_1.getWorkflowContext)(); if (!ctx) { return (0, ids_1.generateTimerId)(); } const id = `timer-${ctx.timerCursor}`; ctx.timerCursor++; return id; } /** * Run code in workflow context (internal use by workers) */ static _run(ctx, fn) { return (0, context_1.runInWorkflowContext)(ctx, fn); } } exports.WorkflowStub = WorkflowStub;