durabull
Version:
A durable workflow engine built on top of BullMQ and Redis
213 lines (184 loc) • 7.34 kB
text/typescript
import { Workflow } from '../Workflow';
import { WorkflowRecord, HistoryEvent } from './history';
import { WorkflowWaitError, WorkflowContinueAsNewError } from '../errors';
import { runInWorkflowContext } from './context';
import { getSignalMethods } from '../decorators';
const isPromiseLike = <T = unknown>(value: unknown): value is PromiseLike<T> => {
return Boolean(value) && typeof (value as { then?: unknown }).then === 'function';
};
export interface ReplayResult {
workflow: Workflow<unknown[], unknown>;
result?: unknown;
error?: unknown;
status: 'completed' | 'failed' | 'running' | 'waiting' | 'continued';
newWorkflowId?: string;
signalCursor?: Record<string, number>;
}
export interface ReplayOptions {
workflowId: string;
workflow: Workflow<unknown[], unknown>;
record: WorkflowRecord;
history: { events: HistoryEvent[]; cursor: number };
signals: Array<{ ts: number; name: string; payload: unknown }>;
isResume: boolean;
getHistory?: () => Promise<{ events: HistoryEvent[]; cursor: number }>;
onStep?: (cursor: number, history: { events: HistoryEvent[]; cursor: number }) => Promise<void>;
}
export class ReplayEngine {
static async run(options: ReplayOptions): Promise<ReplayResult> {
const { workflowId, workflow, record, isResume, signals, onStep } = options;
let { history } = options;
const context = {
workflowId,
workflow,
record,
history,
isResume,
clockCursor: 0,
timerCursor: 0,
sideEffectCursor: 0,
activityCursor: 0,
childWorkflowCursor: 0,
};
const signalMethods = getSignalMethods(workflow.constructor);
const sortedSignals = [...signals].sort((a, b) => a.ts - b.ts);
const signalCursor: Record<string, number> = { ...(record.signalCursor ?? {}) };
let signalCursorDirty = false;
const replaySignals = (
index: number,
opts: { initial: boolean; log?: HistoryEvent; nextLog?: HistoryEvent }
) => {
if (signalMethods.length === 0 || signals.length === 0) {
return;
}
const key = index.toString();
if (!opts.initial && !opts.log) {
return;
}
let lowerBound = signalCursor[key] ?? Number.NEGATIVE_INFINITY;
if (opts.log) {
lowerBound = Math.max(lowerBound, opts.log.ts ?? Number.NEGATIVE_INFINITY);
}
const upperBound = opts.nextLog ? opts.nextLog.ts : Number.POSITIVE_INFINITY;
const toReplay = sortedSignals.filter(
(signal) => signal.ts > lowerBound && signal.ts <= upperBound
);
for (const signal of toReplay) {
if (signalMethods.includes(signal.name)) {
const argsForSignal = Array.isArray(signal.payload)
? signal.payload
: [signal.payload];
(workflow as unknown as Record<string, (...args: unknown[]) => void>)[signal.name](...argsForSignal);
}
}
if (toReplay.length > 0) {
signalCursor[key] = toReplay[toReplay.length - 1].ts;
signalCursorDirty = true;
}
};
let currentIndex = history.cursor ?? 0;
const effectiveArgs = record.args ?? [];
const getLogsForIndex = async (
index: number
): Promise<{ log?: HistoryEvent; nextLog?: HistoryEvent }> => {
if (options.getHistory) {
history = await options.getHistory();
// CRITICAL: Update context.history so ActivityStub checks against latest history
context.history = history;
}
return {
log: history.events[index],
nextLog: history.events[index + 1],
};
};
const initialLogs = await getLogsForIndex(currentIndex);
replaySignals(currentIndex, { initial: true, ...initialLogs });
let generator: AsyncGenerator<unknown, unknown, unknown>;
let result: IteratorResult<unknown, unknown> | undefined;
try {
await runInWorkflowContext(context, async () => {
generator = workflow.execute(...effectiveArgs);
result = await generator.next();
});
if (typeof result === 'undefined') {
return {
workflow,
error: new Error('Workflow did not yield a result before replay loop.'),
status: 'failed',
signalCursor: signalCursorDirty ? signalCursor : undefined
};
}
while (!result.done) {
let { log, nextLog } = await getLogsForIndex(currentIndex);
while (log && log.type === 'timer-fired') {
currentIndex++;
({ log, nextLog } = await getLogsForIndex(currentIndex));
}
replaySignals(currentIndex, { initial: false, log, nextLog });
if (isPromiseLike(result.value)) {
try {
const resolved = await runInWorkflowContext(context, async () => await result!.value);
currentIndex++;
if (onStep) {
history.cursor = currentIndex;
await onStep(currentIndex, history);
}
result = await runInWorkflowContext(context, async () => await generator.next(resolved));
} catch (error) {
if (error instanceof WorkflowWaitError || (error as Error).name === 'WorkflowWaitError') {
return {
workflow,
status: 'waiting',
signalCursor: signalCursorDirty ? signalCursor : undefined
};
}
if (error instanceof WorkflowContinueAsNewError || (error as Error).name === 'WorkflowContinueAsNewError') {
return {
workflow,
status: 'continued',
newWorkflowId: (error as WorkflowContinueAsNewError).workflowId,
signalCursor: signalCursorDirty ? signalCursor : undefined
};
}
result = await runInWorkflowContext(context, async () => await generator.throw(error));
}
} else {
currentIndex++;
if (onStep) {
history.cursor = currentIndex;
await onStep(currentIndex, history);
}
result = await runInWorkflowContext(context, async () => await generator.next(result!.value));
}
}
return {
workflow,
result: result!.value,
status: 'completed',
signalCursor: signalCursorDirty ? signalCursor : undefined
};
} catch (error) {
if (error instanceof WorkflowWaitError || (error as Error).name === 'WorkflowWaitError') {
return {
workflow,
status: 'waiting',
signalCursor: signalCursorDirty ? signalCursor : undefined
};
}
if (error instanceof WorkflowContinueAsNewError || (error as Error).name === 'WorkflowContinueAsNewError') {
return {
workflow,
status: 'continued',
newWorkflowId: (error as WorkflowContinueAsNewError).workflowId,
signalCursor: signalCursorDirty ? signalCursor : undefined
};
}
return {
workflow,
error,
status: 'failed',
signalCursor: signalCursorDirty ? signalCursor : undefined
};
}
}
}