UNPKG

@convex-dev/workflow

Version:

Convex component for durably executing workflows.

587 lines (553 loc) 17 kB
import { vResultValidator } from "@convex-dev/workpool"; import { assert } from "convex-helpers"; import { paginationOptsValidator, type FunctionHandle, type PaginationResult, } from "convex/server"; import { type Infer, v } from "convex/values"; import { internalMutation, internalQuery, mutation, type MutationCtx, query, } from "./_generated/server.js"; import { type Logger, logLevel } from "./logging.js"; import { getWorkflow } from "./model.js"; import { getWorkpool } from "./pool.js"; import schema, { journalDocument, vOnComplete, workflowDocument, type JournalEntry, } from "./schema.js"; import { getDefaultLogger } from "./utils.js"; import { type WorkflowId, type OnCompleteArgs, type WorkflowStep, type EventId, vPaginationResult, vWorkflowStep, type SchedulerOptions, type PublicWorkflow, vPublicWorkflow, } from "../types.js"; import { api, internal } from "./_generated/api.js"; import { formatErrorWithStack } from "../shared.js"; import type { Doc, Id } from "./_generated/dataModel.js"; import { paginator } from "convex-helpers/server/pagination"; const createArgs = v.object({ workflowName: v.string(), workflowHandle: v.string(), workflowArgs: v.any(), maxParallelism: v.optional(v.number()), onComplete: v.optional(vOnComplete), startAsync: v.optional(v.boolean()), createOnly: v.optional(v.boolean()), // TODO: ttl }); export const create = mutation({ args: createArgs, returns: v.id("workflows"), handler: createHandler, }); export async function createHandler( ctx: MutationCtx, args: Infer<typeof createArgs>, schedulerOptions?: SchedulerOptions, ) { const console = await getDefaultLogger(ctx); await updateMaxParallelism(ctx, console, args.maxParallelism); const workflowId = await ctx.db.insert("workflows", { name: args.workflowName, workflowHandle: args.workflowHandle, args: args.workflowArgs, generationNumber: 0, onComplete: args.onComplete, }); console.debug( `Created workflow ${workflowId}:`, args.workflowArgs, args.workflowHandle, ); if (args.startAsync) { assert( !args.createOnly, "Cannot startAsync and createOnly at the same time", ); const workpool = await getWorkpool(ctx, args); await workpool.enqueueMutation( ctx, args.workflowHandle as FunctionHandle<"mutation">, { workflowId, generationNumber: 0 }, { name: args.workflowName, onComplete: internal.pool.handlerOnComplete, context: { workflowId, generationNumber: 0 }, ...schedulerOptions, }, ); } else if (!args.createOnly) { // If we can't start it, may as well not create it, eh? Fail fast... await ctx.runMutation(args.workflowHandle as FunctionHandle<"mutation">, { workflowId, generationNumber: 0, }); } return workflowId; } export const getStatus = query({ args: { workflowId: v.id("workflows"), }, returns: v.object({ workflow: workflowDocument, inProgress: v.array(journalDocument), logLevel: logLevel, }), handler: async (ctx, args) => { const workflow = await ctx.db.get("workflows", args.workflowId); assert(workflow, `Workflow not found: ${args.workflowId}`); const console = await getDefaultLogger(ctx); const inProgress = await ctx.db .query("steps") .withIndex("inProgress", (q) => q.eq("step.inProgress", true).eq("workflowId", args.workflowId), ) .collect(); console.debug(`${args.workflowId} blocked by`, inProgress); return { workflow, inProgress, logLevel: console.logLevel }; }, }); function publicWorkflowId(workflowId: Id<"workflows">): WorkflowId { return workflowId as any; } function publicWorkflow(workflow: Doc<"workflows">): PublicWorkflow { return { workflowId: publicWorkflowId(workflow._id), name: workflow.name, args: workflow.args, context: workflow.onComplete?.context, runResult: workflow.runResult, } satisfies PublicWorkflow; } function publicStep(step: JournalEntry): WorkflowStep { const commonFields = { workflowId: publicWorkflowId(step.workflowId), name: step.step.name, stepId: step._id, stepNumber: step.stepNumber, args: step.step.args, runResult: step.step.runResult, startedAt: step.step.startedAt, completedAt: step.step.completedAt, }; switch (step.step.kind) { case "event": return { ...commonFields, kind: "event", eventId: step.step.eventId as unknown as EventId, }; case "workflow": return { ...commonFields, kind: "workflow", nestedWorkflowId: publicWorkflowId(step.step.workflowId!), }; case "function": return { ...commonFields, kind: "function", workId: step.step.workId, }; case "sleep": return { ...commonFields, kind: "sleep", workId: step.step.workId!, }; default: throw new Error(`Unknown step kind: ${(step.step as any).kind}`); } } export const list = query({ args: { order: v.union(v.literal("asc"), v.literal("desc")), paginationOpts: paginationOptsValidator, }, returns: vPaginationResult(vPublicWorkflow), handler: async (ctx, args) => { const result = await paginator(ctx.db, schema) .query("workflows") .order(args.order) .paginate(args.paginationOpts); return { ...result, page: result.page.map(publicWorkflow), } as PaginationResult<Infer<typeof vPublicWorkflow>>; }, }); export const listByName = query({ args: { name: v.string(), order: v.union(v.literal("asc"), v.literal("desc")), paginationOpts: paginationOptsValidator, }, returns: vPaginationResult(vPublicWorkflow), handler: async (ctx, args) => { const result = await paginator(ctx.db, schema) .query("workflows") .withIndex("name", (q) => q.eq("name", args.name)) .order(args.order) .paginate(args.paginationOpts); return { ...result, page: result.page.map(publicWorkflow), } as PaginationResult<Infer<typeof vPublicWorkflow>>; }, }); export const listSteps = query({ args: { workflowId: v.id("workflows"), order: v.union(v.literal("asc"), v.literal("desc")), paginationOpts: paginationOptsValidator, }, returns: vPaginationResult(vWorkflowStep), handler: async (ctx, args) => { const result = await paginator(ctx.db, schema) .query("steps") .withIndex("workflow", (q) => q.eq("workflowId", args.workflowId)) .order(args.order) .paginate(args.paginationOpts); return { ...result, page: result.page.map(publicStep), } as PaginationResult<Infer<typeof vWorkflowStep>>; }, }); const restartArgs = v.object({ workflowId: v.id("workflows"), from: v.optional(v.union(v.number(), v.string())), startAsync: v.optional(v.boolean()), }); export const restart = mutation({ args: restartArgs, returns: v.null(), handler: restartHandler, }); export async function restartHandler( ctx: MutationCtx, args: Infer<typeof restartArgs>, ) { const workflow = await ctx.db.get("workflows", args.workflowId); assert(workflow, `Workflow not found: ${args.workflowId}`); const console = await getDefaultLogger(ctx); if (!workflow.runResult) { throw new Error(`Workflow is still running: ${args.workflowId}`); } // Delete steps from the specified point if (args.from !== undefined) { if (typeof args.from === "number") { if (args.from < 0) { throw new Error(`Step number cannot be negative: ${args.from}`); } const stepsToDelete = await ctx.db .query("steps") .withIndex("workflow", (q) => q .eq("workflowId", args.workflowId) .gte("stepNumber", args.from as number), ) .collect(); if (stepsToDelete.length === 0) { console.warn( `Step number ${args.from} not found in workflow ${args.workflowId}`, ); } await deleteSteps(ctx, stepsToDelete); } else { // Walk backwards to find step by name, collecting steps to delete const stepsDesc = ctx.db .query("steps") .withIndex("workflow", (q) => q.eq("workflowId", args.workflowId)) .order("desc"); let found = false; const toDelete: Doc<"steps">[] = []; for await (const step of stepsDesc) { toDelete.push(step); if (step.step.name === args.from) { found = true; break; } } if (!found) { throw new Error( `Step "${args.from}" not found in workflow ${args.workflowId}`, ); } await deleteSteps(ctx, toDelete); } } // Increment generation number and clear result const generationNumber = workflow.generationNumber + 1; await ctx.db.patch("workflows", args.workflowId, { generationNumber, runResult: undefined, }); console.event("retry", { workflowId: args.workflowId, name: workflow.name, from: args.from, }); if (args.startAsync) { const workpool = await getWorkpool(ctx, {}); await workpool.enqueueMutation( ctx, workflow.workflowHandle as FunctionHandle<"mutation">, { workflowId: args.workflowId, generationNumber }, { name: workflow.name, onComplete: internal.pool.handlerOnComplete, context: { workflowId: args.workflowId, generationNumber }, }, ); } else { await ctx.runMutation( workflow.workflowHandle as FunctionHandle<"mutation">, { workflowId: args.workflowId, generationNumber, }, ); } } export const cancel = mutation({ args: { workflowId: v.id("workflows"), }, returns: v.null(), handler: async (ctx, { workflowId }) => { const workflow = await ctx.db.get("workflows", workflowId); assert(workflow, `Workflow not found: ${workflowId}`); await completeHandler(ctx, { workflowId, generationNumber: workflow.generationNumber, runResult: { kind: "canceled" }, }); }, }); const completeArgs = v.object({ workflowId: v.id("workflows"), generationNumber: v.number(), runResult: vResultValidator, }); export const complete = mutation({ args: completeArgs, returns: v.null(), handler: completeHandler, }); // When the overall workflow completes (successfully or not). export async function completeHandler( ctx: MutationCtx, args: Infer<typeof completeArgs>, ) { const workflow = await getWorkflow( ctx, args.workflowId, args.generationNumber, ); const console = await getDefaultLogger(ctx); if (workflow.runResult) { throw new Error(`Workflow not running: ${workflow}`); } workflow.runResult = args.runResult; console.event("completed", { workflowId: workflow._id, name: workflow.name, status: workflow.runResult.kind, overallDurationMs: Date.now() - workflow._creationTime, }); if (workflow.runResult.kind === "canceled") { // We bump it so no in-flight steps succeed / we don't race to complete. workflow.generationNumber += 1; // TODO: can we cancel these asynchronously if there's more than one? const inProgress = await ctx.db .query("steps") .withIndex("inProgress", (q) => q.eq("step.inProgress", true).eq("workflowId", args.workflowId), ) .collect(); if (inProgress.length > 0) { const workpool = await getWorkpool(ctx, {}); for (const { step } of inProgress) { if (!step.kind || step.kind === "function" || step.kind === "sleep") { if (step.workId) { await workpool.cancel(ctx, step.workId); } } else if (step.kind === "workflow") { if (step.workflowId) { await ctx.runMutation(api.workflow.cancel, { workflowId: step.workflowId, }); } } } } console.debug(`Canceled workflow:`, workflow); } // Write the workflow so the onComplete can observe the updated status. await ctx.db.replace("workflows", workflow._id, workflow); if (workflow.onComplete) { try { await ctx.runMutation( workflow.onComplete.fnHandle as FunctionHandle< "mutation", OnCompleteArgs >, { workflowId: workflow._id as unknown as WorkflowId, result: workflow.runResult, context: workflow.onComplete.context, }, ); } catch (error) { const message = formatErrorWithStack(error); console.error("Error calling onComplete", message); await ctx.db.insert("onCompleteFailures", { ...args, error: message, }); } } // TODO: delete everything unless ttl is set console.debug(`Completed workflow ${workflow._id}:`, workflow); } export const cleanup = mutation({ args: { workflowId: v.string(), force: v.optional(v.boolean()), }, returns: v.boolean(), handler: async (ctx, args) => { const workflowId = ctx.db.normalizeId("workflows", args.workflowId); if (!workflowId) { throw new Error(`Invalid workflow ID: ${args.workflowId}`); } const workflow = await ctx.db.get("workflows", workflowId); if (!workflow) { return false; } const logger = await getDefaultLogger(ctx); // TODO: allow cleaning up a workflow from inside it / in the onComplete hook if (!workflow.runResult) { if (!args.force) { logger.debug( `Can't clean up workflow ${workflowId} since it hasn't completed.`, ); return false; } logger.debug(`Workflow ${workflowId} is not completed, forcing anyways`); } logger.debug(`Cleaning up workflow ${workflowId}`, workflow); await ctx.db.delete("workflows", workflowId); await cleanupStepsFrom(ctx, workflowId, 0); return true; }, }); export const cleanupContinue = internalMutation({ args: { workflowId: v.id("workflows"), fromStepNumber: v.number(), }, returns: v.null(), handler: async (ctx, args) => { await cleanupStepsFrom(ctx, args.workflowId, args.fromStepNumber); return null; }, }); const CLEANUP_BATCH_SIZE = 256; async function transactionBudgetMostlyConsumed( ctx: MutationCtx, ): Promise<boolean> { const m = await ctx.meta.getTransactionMetrics(); return ( m.bytesRead.used > m.bytesRead.remaining || m.bytesWritten.used > m.bytesWritten.remaining || m.databaseQueries.used > m.databaseQueries.remaining || m.documentsRead.used > m.documentsRead.remaining || m.documentsWritten.used > m.documentsWritten.remaining || m.functionsScheduled.used > m.functionsScheduled.remaining || m.scheduledFunctionArgsBytes.used > m.scheduledFunctionArgsBytes.remaining ); } async function updateMaxParallelism( ctx: MutationCtx, console: Logger, maxParallelism: number | undefined, ) { const config = await ctx.db.query("config").first(); if (config) { if (maxParallelism && maxParallelism !== config.maxParallelism) { console.warn("Updating max parallelism to", maxParallelism); await ctx.db.patch("config", config._id, { maxParallelism }); } } else { await ctx.db.insert("config", { maxParallelism }); } } async function cleanupStepsFrom( ctx: MutationCtx, workflowId: Id<"workflows">, fromStepNumber: number, ) { while (true) { const batch = await ctx.db .query("steps") .withIndex("workflow", (q) => q.eq("workflowId", workflowId).gte("stepNumber", fromStepNumber), ) .take(CLEANUP_BATCH_SIZE); if (batch.length === 0) return; await deleteSteps(ctx, batch); fromStepNumber = batch[batch.length - 1].stepNumber + 1; if (batch.length < CLEANUP_BATCH_SIZE) return; if (await transactionBudgetMostlyConsumed(ctx)) { await ctx.scheduler.runAfter(0, internal.workflow.cleanupContinue, { workflowId, fromStepNumber, }); return; } } } async function deleteSteps(ctx: MutationCtx, batch: Doc<"steps">[]) { const nestedWorkflowIds: Id<"workflows">[] = []; await Promise.all( batch.map(async (entry) => { await ctx.db.delete("steps", entry._id); if (entry.step.kind === "event" && entry.step.eventId) { await ctx.db.delete("events", entry.step.eventId); } else if (entry.step.kind === "workflow" && entry.step.workflowId) { nestedWorkflowIds.push(entry.step.workflowId); } }), ); if (nestedWorkflowIds.length > 0) { const workpool = await getWorkpool(ctx, {}); await workpool.enqueueMutationBatch( ctx, api.workflow.cleanup, nestedWorkflowIds.map((id) => ({ workflowId: id, force: true })), ); } } export const sleep = internalQuery({ args: {}, returns: v.null(), handler: async () => null, }); // eslint-disable-next-line @typescript-eslint/no-unused-vars const console = "THIS IS A REMINDER TO USE getDefaultLogger";