UNPKG

@convex-dev/workflow

Version:

Convex component for durably executing workflows.

278 lines (265 loc) 9.01 kB
import type { RetryBehavior, RunResult, WorkpoolOptions, } from "@convex-dev/workpool"; import { BaseChannel } from "async-channel"; import { createFunctionHandle, type FunctionReference, type FunctionType, type FunctionVisibility, type GenericDataModel, type GenericMutationCtx, } from "convex/server"; import { convexToJson, getConvexSize, type Value } from "convex/values"; import { type JournalEntry, type Step } from "../component/schema.js"; import type { IdsToStrings, TransactionLimits, WorkflowComponent, } from "./types.js"; import { MAX_JOURNAL_SIZE, formatErrorWithStack } from "../shared.js"; import type { EventId, SchedulerOptions } from "../types.js"; import { pick } from "convex-helpers"; export type WorkerResult = | { type: "handlerDone"; runResult: RunResult } | { type: "executorBlocked" }; export type StepRequest = { name: string; target: | { kind: "function"; functionType: FunctionType; function: FunctionReference<FunctionType, FunctionVisibility>; args: Record<string, unknown>; } | { kind: "event"; args: { eventId?: EventId }; } | { kind: "workflow"; function: FunctionReference<"mutation", "internal">; args: Record<string, unknown>; } | { kind: "sleep"; args: Record<string, never>; }; retry: RetryBehavior | boolean | undefined; inline: boolean; unstableArgs: boolean; transactionLimits: TransactionLimits | undefined; schedulerOptions: SchedulerOptions; resolve: (result: RunResult) => void; }; export class StepExecutor { private journalEntrySize: number; constructor( private workflowId: string, private generationNumber: number, private ctx: GenericMutationCtx<GenericDataModel>, private component: WorkflowComponent, private journalEntries: Array<JournalEntry>, private receiver: BaseChannel<StepRequest>, private now: number, private workpoolOptions: WorkpoolOptions | undefined, ) { this.journalEntrySize = journalEntries.reduce( (size, entry) => size + getConvexSize(entry), 0, ); if (this.journalEntrySize > MAX_JOURNAL_SIZE) { // This should never happen, but we'll throw an error just in case. throw new Error(journalSizeError(this.journalEntrySize, this.workflowId)); } } async run(): Promise<WorkerResult> { while (true) { const message = await this.receiver.get(); // In the future we can correlate the calls to entries by handle, args, // etc. instead of just ordering. As is, the fn order can't change. const entry = this.journalEntries.shift(); if (entry) { this.completeMessage(message, entry); continue; } const messages = [message]; const size = this.receiver.bufferSize; for (let i = 0; i < size; i++) { const message = await this.receiver.get(); messages.push(message); } const entries = await this.startSteps(messages); if (entries.every((entry) => entry.step.runResult)) { for (let i = 0; i < entries.length; i++) { const entry = entries[i]; this.completeMessage(messages[i], entry); } continue; } return { type: "executorBlocked", }; } } getGenerationState() { if (this.journalEntries.length <= this.receiver.bufferSize) { return { now: this.now, latest: true }; } return { // We use the next entry's startedAt, since we're in code just before that // step is invoked. We use the bufferSize, since multiple steps may be // currently enqueued in one generation, but the code after it has already // started executing. now: this.journalEntries[this.receiver.bufferSize].step.startedAt, latest: false, }; } completeMessage(message: StepRequest, entry: JournalEntry) { if (entry.step.inProgress) { throw new Error( `Assertion failed: not blocked but have in-progress journal entry`, ); } const stepFields = pick( entry.step, message.unstableArgs ? ["name", "kind"] : ["name", "kind", "args"], ); const messageFields = message.unstableArgs ? { name: message.name, kind: message.target.kind } : { name: message.name, kind: message.target.kind, args: message.target.args as Value, }; const stepJson = JSON.stringify(convexToJson(stepFields)); const messageJson = JSON.stringify(convexToJson(messageFields)); if (stepJson !== messageJson) { throw new Error( `Journal entry mismatch:\n\n${stepJson}\n\n!==\n\n${messageJson}`, ); } if (entry.step.runResult === undefined) { throw new Error( `Assertion failed: no outcome for completed function call`, ); } message.resolve(entry.step.runResult); } async startSteps(messages: StepRequest[]): Promise<JournalEntry[]> { const steps = await Promise.all( messages.map(async (message) => { const args = message.target.args ?? {}; const target = message.target; let runResult: RunResult | undefined; if (message.inline) { if (target.kind !== "function" || target.functionType === "action") { throw new Error( "Inline execution is only supported for queries and mutations.", ); } try { const result = target.functionType === "query" ? // cast until transactionLimits is shipped / peer dep await (this.ctx.runQuery as any)( target.function as FunctionReference< typeof target.functionType >, target.args, { transactionLimits: message.transactionLimits }, ) : // cast until transactionLimits is shipped / peer dep await (this.ctx.runMutation as any)( target.function as FunctionReference< typeof target.functionType >, target.args, { transactionLimits: message.transactionLimits }, ); runResult = { kind: "success", returnValue: result ?? null }; } catch (error: unknown) { runResult = { kind: "failed", error: formatErrorWithStack(error) }; } } const commonFields = { inProgress: !runResult, name: message.name, args, argsSize: getConvexSize(args as Value), runResult, startedAt: this.now, completedAt: runResult ? this.now : undefined, } satisfies Omit<Step, "kind">; let step: IdsToStrings<Step>; switch (target.kind) { case "function": step = { kind: "function" as const, functionType: target.functionType, handle: await createFunctionHandle(target.function), ...commonFields, }; break; case "workflow": step = { kind: "workflow" as const, handle: await createFunctionHandle(target.function), ...commonFields, }; break; case "event": step = { kind: "event" as const, eventId: target.args.eventId, ...commonFields, args: target.args, }; break; case "sleep": step = { kind: "sleep" as const, ...commonFields, }; break; default: throw new Error(`Unknown step kind: ${(target as any).kind}`); } return { retry: message.retry, schedulerOptions: message.schedulerOptions, step, }; }), ); const entries = (await this.ctx.runMutation( this.component.journal.startSteps, { workflowId: this.workflowId, generationNumber: this.generationNumber, steps, workpoolOptions: this.workpoolOptions, }, )) as JournalEntry[]; for (const entry of entries) { this.journalEntrySize += getConvexSize(entry); if (this.journalEntrySize > MAX_JOURNAL_SIZE) { throw new Error( journalSizeError(this.journalEntrySize, this.workflowId) + ` The failing step was ${entry.step.name} (${entry._id})`, ); } } return entries; } } function journalSizeError(size: number, workflowId: string): string { const lines = [ `Workflow ${workflowId} journal size limit exceeded (${size} bytes > ${MAX_JOURNAL_SIZE} bytes).`, "Consider breaking up the workflow into multiple runs, using smaller step \ arguments or return values, or using fewer steps.", ]; return lines.join("\n"); }