UNPKG

@convex-dev/workflow

Version:

Convex component for durably executing workflows.

823 lines (650 loc) 28.1 kB
# Convex Durable Workflows [![npm version](https://badge.fury.io/js/@convex-dev%2Fworkflow.svg?)](https://badge.fury.io/js/@convex-dev%2Fworkflow) <!-- START: Include on https://convex.dev/components --> The Workflow component allows you to write **durable functions**: code that orchestrates potentially-long-lived operations reliably, even in the face of server restarts. It can **pause** indefinitely while it waits for an asynchronous **event** or **sleep** for an arbitrary amount of time, without consuming any resources in the interim. Step execution can be **determined dynamically** with branching, loops, try/catch and more, all via regular code. Local variables, for-loops, console logging, etc. work as you'd expect, even though under the hood the function will be suspended and re-hydrated between steps. Steps are regular Convex queries, mutations, or actions, and can run **sequentially or in parallel** (e.g. `Promise.all`), using output from previous steps or local variables. The overall workflow and each step has type-safe and runtime-validated **arguments** and **return value**. Workflows can be canceled and **restarted** from an arbitrary step, allowing you to recover failed workflows after third party outages or fixing your code. The **status** can be observed by many users simultaneously via regular **reactive-by-default** Convex queries, and the history of each step's execution is likewise live-updating. **Retry behavior** for each action step is configurable, mutations have **exactly-once** execution (ignoring rollbacks due to database conflicts, which are automatically retried), and the overall workflow is guaranteed to run to completion, with exactly-once execution of an `onComplete` handler. Uses a Workpool under the hood to enable **parallelism limits** for steps, to avoid spikes of asynchronous work consuming too many resources. ```ts import { WorkflowManager } from "@convex-dev/workflow"; import { components } from "./_generated/api"; export const workflow = new WorkflowManager(components.workflow); export const userOnboarding = workflow .define({ args: { userId: v.id("users") }, }) .handler(async (step, args): Promise<void> => { let result = await step.runAction( internal.llm.generateCustomContent, { userId: args.userId }, // Retry this on transient errors with the default retry policy. { retry: true }, ); while (result.requiresRefinement) { // Run a whole workflow as a single step. result = await step.runWorkflow(internal.llm.refineContentWorkflow, { userId: args.userId, currentResult: result, }); } const email = await step.runMutation( internal.emails.sendWelcomeEmail, { userId: args.userId, content: result.content }, // Optimization: run the mutation synchronously from this transaction. { inline: true }, ); if (email.status === "needsVerification") { // Waits until verification is completed asynchronously. await step.awaitEvent({ name: "emailHasBeenVerified" }); } // Wait 3 days before starting follow-ups. const DAY = 24 * 60 * 60 * 1000; await step.sleep(3 * DAY); for (let i = 0; i < 3; i++) { const sendTime = await getNextBestEmailTime(step, args.userId); const status = await step.runMutation( internal.emails.sendFollowUpEmailMaybe, { userId: args.userId }, // Waits until this time to run this step. { runAt: sendTime }, ); if (!status.ok) break; } }); ``` ## How it works The workflow tracks each `step` as it goes, executing steps asynchronously, and resuming the workflow's handler where it left off when the step completes. If a step fails, it will either retry based on the configured policy, or throw a catch-able exception in the workflow handler, allowing graceful recovery. While steps are executing, the workflow handler is not running. When it is time to run the next step, it re-executes the code, deterministically replaying the history until it finds the next step. The workflow itself is also run via the Workpool, so exceptions thrown within the workflow will get delivered to the `onComplete` handler. ## Installation First, add `@convex-dev/workflow` to your Convex project: ```sh npm install @convex-dev/workflow ``` Then, install the component within your `convex/convex.config.ts` file: ```ts // convex/convex.config.ts import workflow from "@convex-dev/workflow/convex.config.js"; import { defineApp } from "convex/server"; const app = defineApp(); app.use(workflow); export default app; ``` Finally, create a workflow manager within your `convex/` folder, and point it to the installed component: ```ts // convex/index.ts import { WorkflowManager } from "@convex-dev/workflow"; import { components } from "./_generated/api"; export const workflow = new WorkflowManager(components.workflow); ``` ## Usage The first step is to define a workflow using `workflow.define()`. This function is designed to feel like a Convex action but with a few restrictions: 1. The workflow runs in the background, so the result can't be directly returned to whatever code starts it. 2. The workflow must be _deterministic_, so it should implement most of its logic by calling out to other Convex functions. We restrict access to some non-deterministic functions like `fetch`, env vars and `crypto`. Others we patch, such as `console` for logging, `Math.random()` (seeded PRNG) and `Date` for time. Note: To help avoid type cycles, always annotate the return type of the `handler` with the return type of the workflow. ```ts export const exampleWorkflow = workflow .define({ args: { exampleArg: v.string() }, returns: v.string(), }) .handler(async (step, args) => { const queryResult = await step.runQuery( internal.example.exampleQuery, args, ); const actionResult = await step.runAction( internal.example.exampleAction, { queryResult }, // pass in results from previous steps! ); return actionResult; }); export const exampleQuery = internalQuery({ args: { exampleArg: v.string() }, handler: async (ctx, args): Promise<string> => { return `The query says... Hi ${args.exampleArg}!`; }, }); export const exampleAction = internalAction({ args: { queryResult: v.string() }, handler: async (ctx, args): Promise<string> => { return args.queryResult + " The action says... Hi back!"; }, }); ``` ### Starting a workflow Once you've defined a workflow, you can start it from any mutation or action using `start()`: ```ts import { start } from "@convex-dev/workflow"; import { internal } from "./_generated/api"; export const kickoffWorkflow = mutation({ handler: async (ctx): Promise<WorkflowId> => { // Starts the workflow immediately, run asynchronously const workflowId = await start(ctx, internal.example.exampleWorkflow, { exampleArg: "James", }); return workflowId; }, }); ``` You can also call workflows directly from code, the CLI or dashboard: ```sh npx convex run example:exampleWorkflow '{ "args": { "exampleArg": "James" } }' ``` ### Handling the workflow's result with onComplete You can handle the workflow's result with `onComplete` by using the `start()` helper. This is useful for cleaning up any resources used by the workflow. Note: when you return things from a workflow, you'll need to specify the return type of your `handler` to break type cycles due to using `internal.*` functions in the body, which then inform the type of the workflow, which is included in the `internal.*` type. You can also specify a `returns` validator to do runtime validation on the return value. If it fails, your `onComplete` handler will be called with an error instead of success. You can also do validation in the `onComplete` handler to have more control over handling that situation. ```ts import { start, vWorkflowId } from "@convex-dev/workflow"; import { vResultValidator } from "@convex-dev/workpool"; export const foo = mutation({ handler: async (ctx): Promise<WorkflowId> => { const name = "James"; const workflowId = await start( ctx, internal.example.exampleWorkflow, { name }, { onComplete: internal.example.handleOnComplete, context: { intent: "welcome", for: "James" }, // can be anything }, ); return workflowId; }, }); export const handleOnComplete = mutation({ args: { workflowId: vWorkflowId, result: vResultValidator, // used to pass through data from the start site. context: v.object({ intent: v.string(), for: v.string() }), }, handler: async (ctx, args): Promise<void> => { const name = args.context.name; if (args.result.kind === "success") { const text = args.result.returnValue; console.log(`${name} result: ${text}`); } else if (args.result.kind === "error") { console.error("Workflow failed", args.result.error); } else if (args.result.kind === "canceled") { console.log("Workflow canceled", args.context); } }, }); ``` ### Running steps in parallel You can run steps in parallel by calling `step.runAction()` multiple times in a `Promise.all()` call. ```ts export const exampleWorkflow = workflow .define({ args: { name: v.string() }, }) .handler(async (step, args): Promise<void> => { const [result1, result2] = await Promise.all([ step.runAction(internal.example.myAction, args), step.runAction(internal.example.myAction, args), ]); }); ``` Note: The workflow will not proceed until all steps fired off at once have completed. Note: if you are starting many tasks at once, it will only start the first 10 (or maxParallelism) at once, to prevent one workflow from starving others. It will start the next batch when all 10 have finished. ### Sleeping and running steps after a delay Use `step.sleep` to pause a workflow for a given duration in milliseconds. The workflow consumes no resources while sleeping. ```ts // Wait one day before continuing await step.sleep(24 * 60 * 60 * 1000); ``` Tip: You can name the sleep step for clarity with a second `{ name }` argument. If you want to defer a specific step, you can use `runAfter` or `runAt` as scheduling options on any step. This delays that particular step's execution: ```ts // Run this action 10 seconds from now. await step.runAction(internal.example.myAction, args, { runAfter: 10_000 }); ``` This is roughly equivalent to doing a sleep first, with the difference being that the "myAction" step is considered "in progress" while it is waiting, and it only enqueues one item into the Workpool (myAction@delay), instead of two (sleep@delay, myAction@now). ### Specifying retry behavior Sometimes actions fail due to transient errors, whether it was an unreliable third-party API or a server restart. You can have the workflow automatically retry actions using best practices (exponential backoff & jitter). By default there are no retries on actions, and if the exception isn't caught in the workflow, it will call the onComplete as a failure. Note: all queries and mutations (including the workflow handler) are retried automatically by Convex on system errors, and no transaction will either partially commit or commit twice (regular Convex guarantees). You can specify default retry behavior for all workflows on the WorkflowManager, or override it on a per-workflow basis. You can also specify a custom retry behavior per-step, to opt-out of retries for actions that may want at-most-once semantics. Workpool options: If you specify any of these, it will override the [`DEFAULT_RETRY_BEHAVIOR`](./src/component/pool.ts). - `defaultRetryBehavior`: The default retry behavior for all workflows. - `maxAttempts`: The maximum number of attempts to retry an action. - `initialBackoffMs`: The initial backoff time in milliseconds. - `base`: The base multiplier for the backoff. Default is 2. - `retryActionsByDefault`: Whether to retry actions, by default is false. - If you specify a retry behavior at the step level, it will always retry. At the step level, you can also specify `true` or `false` to use the default policy or disable retries. ```ts const workflow = new WorkflowManager(components.workflow, { workpoolOptions: { defaultRetryBehavior: { maxAttempts: 3, initialBackoffMs: 100, base: 2, }, retryActionsByDefault: true, // default is false } }); export const exampleWorkflow = workflow .define({ args: { name: v.string() }, // If specified, this will override the workflow manager's default workpoolOptions: { ... }, }) .handler(async (step, args): Promise<void> => { // Uses default retry behavior & retryActionsByDefault await step.runAction(internal.example.myAction, args); // Retries will be attempted with the default behavior await step.runAction(internal.example.myAction, args, { retry: true }); // No retries will be attempted await step.runAction(internal.example.myAction, args, { retry: false }); // Custom retry behavior will be used await step.runAction(internal.example.myAction, args, { retry: { maxAttempts: 2, initialBackoffMs: 100, base: 2 }, }); }); ``` ### Specifying step parallelism You can specify how many steps can run in parallel by setting the `maxParallelism` workpool option. Note: this is not a limit of how many workflows can be in progress. Any number of workflows can be in-flight. This limit is how many queries/mutations/actions can be executing at the same time. If you see that your scheduled functions are getting backlogged, you should decrease this number. You can look for data on the Dashboard's Health and Schedules pages, as well as in log stream data (tip: define alerts on this!). If you don't specify a limit in code, you can set this dynamically without a code push while your app is running. Run this from the CLI (or dashboard function runner): ```sh npx convex run --component workflow utils:updateConfig '{ maxParallelism: 50 }' ``` On a Pro account, avoid exceeding a total of 100 across all your workflows and workpools. If you want to do a lot of work in parallel, you should employ batching, where each workflow operates on a batch of work, e.g. scraping a list of links instead of one link per workflow. ```ts const workflow = new WorkflowManager(components.workflow, { workpoolOptions: { // Note: You must only set this to one value per `components.<name>`! // You can set different values if you "use" multiple instances // with unique names in convex.config.ts. // Tip: use an environment variable for dynamic control maxParallelism: 10, }, }); ``` ### Running queries and mutations inline By default, every `step.runQuery()` and `step.runMutation()` call is dispatched through the workpool, which runs the function in an independent transaction. You can opt in to running a query or mutation **inline**, sharing the workflow's transaction by passing `{ inline: true }`: ```ts export const myWorkflow = workflow .define({ args: { userId: v.id("users") }, }) .handler(async (step, args): Promise<string> => { const user = await step.runQuery( internal.example.getUser, { userId: args.userId }, { inline: true }, ); const updated = await step.runMutation( internal.example.updateUser, { userId: args.userId, name: user.name + "!" }, { inline: true }, ); return updated; }); ``` Because inline functions share the workflow's transaction, their reads and writes count toward the same [transaction limits](https://docs.convex.dev/production/state/limits#transactions). If a step reads or writes a large amount of data, it's better to leave it running through the work pool (the default) so it gets its own transaction budget. ### Checking a workflow's status Calling a workflow returns a `WorkflowId` string, which can then be used for querying a workflow's status. ```ts import { vWorkflowId, getStatus, WorkflowStatus } from "@convex-dev/workflow"; import { components } from "./_generated/api"; import { query } from "./_generated/server"; export const runWorkflowAndPoll = query({ args: { workflowId: vWorkflowId }, handler: async (ctx, args): Promise<WorkflowStatus> => { await checkAuth(ctx, args); const status = await getStatus(ctx, components.workflow, args.workflowId); console.log("Workflow status", status); console.log("Running:", status.type === "inProgress" ? status.running : []); return status; }, }); ``` Reading the status from a query will subscribe to the status, so your frontend can reactively update as the workflow progresses. ### Canceling a workflow You can cancel a workflow with `cancel()`, halting the workflow's execution immediately. In-progress calls to `step.runAction()`, however, will finish executing. ```ts import { cancel } from "@convex-dev/workflow"; // ... Inside a mutation or action await cancel(ctx, components.workflow, workflowId); ``` ### Restart a failed workflow If you want to re-run a workflow from a specific point, you can do so with `restart(...)`. By default it will retry the handler using the existing history of steps. ```ts import { restart } from "@convex-dev/workflow"; // Re-executes the handler with the existing history of steps. // This is useful if you had a bug in the handler code itself. await restart(ctx, components.workflow, args.workflowId); ``` If a certain step failed, you can restart from that step onwards by providing the step number, name, or function reference (`internal.foo.bar` e.g.). Events are named after the event name or event ID if no name is given. You can get the step number by listing the steps and using the `stepNumber`. ```ts // Restart from a step number (0-indexed) await restart(ctx, components.workflow, workflowId, { from: 2 }); // Restart from a step by name await restart(ctx, components.workflow, workflowId, { from: "eventName" }); // Restart from a step by function reference await restart(ctx, components.workflow, workflowId, { from: internal.example.myAction, }); ``` It will find the step by number, or the last step with the given name or function reference and delete all subsequent steps, so the workflow will start from that step when re-executing. By default it will execute the handler in the same transaction so any errors will be immediately visible. However, this means that on a handler error, the restart itself will also be rolled back and the workflow will be unchanged. If you want to retry the workflow in a separate transaction, you can do so by passing `startAsync: true`. This will enqueue the handler via the workpool to run asynchronously. ```ts await restart(ctx, components.workflow, workflowId, { startAsync: true }); ``` ### Cleaning up a workflow After a workflow has completed, you can clean up its storage with `cleanup()`. Completed workflows are not automatically cleaned up by the system. ```ts import { cleanup, vWorkflowId, vResultValidator } from "@convex-dev/workflow"; export const afterWorkflow = mutation({ args: { workflowId: vWorkflowId, result: vResultValidator, context: v.any(), }, handler: async (ctx, args): Promise<void> => { // Clean up a completed workflow's storage. await cleanup(ctx, components.workflow, args.workflowId); }, }); ``` You could alternatively use the `list` API to paginate through and clean up old workflows from an hourly cron. ### Specifying a custom name for a step You can specify a custom name for a step by passing a `name` option to the step. This allows the events emitted to your logs to be more descriptive, as well as restarting from a given step to be more clear. By default it uses the `file/folder:function` name. Note: The workflow will fail if the name of the function changes between when the workflow is started and when it is resumed after some step (potentially much later if it waited on an event or a `sleep`). ```ts export const exampleWorkflow = workflow.define({ args: { name: v.string() }, handler: async (step, args): Promise<void> => { await step.runAction(internal.example.foo, args, { name: "some action" }); }, }); ``` Tip: If you want to rename or move a function that is for a given step, you can use the name of the old function. ### Waiting for external events Use `step.awaitEvent` inside a workflow handler to pause until an external event is triggered. This is useful for human-in-the-loop flows or coordinating with other asynchronous flows. Wait for an indefinite amount of time and continue when the event is triggered. At its simplest, you can wait for an event **by name**: ```ts await step.awaitEvent({ name: "eventName" }); ``` This will wait for the first un-consumed event with the name "eventName", and will continue immediately if one was already sent. Events are sent by calling `sendEvent` from a mutation or action: ```ts import { sendEvent } from "@convex-dev/workflow"; await sendEvent(ctx, components.workflow, { name: "eventName", workflowId, }); ``` Note: You must send the event on the same workflow component that is waiting for it, and the workflowId must match the ID of the workflow that is waiting for it. #### Sending values or errors with the event You can send a value with the event using the `value` property. For type safety and runtime validation, provide a validator on the sending and receiving sides. ```ts const sharedValidator = v.number(); // In the workflow: const event = await step.awaitEvent({ name, validator: sharedValidator }); // From elsewhere: await sendEvent(ctx, components.workflow, { name, workflowId, value: 42 }); ``` To send an error, use the `error` property. This will cause `step.awaitEvent` to throw an error. ```ts await sendEvent(ctx, components.workflow, { name, workflowId, error: "An error occurred", }); ``` #### Sharing event definitions Use `defineEvent` to define an event's name and validator in one place, then share it between the workflow and the sender: ```ts const approvalEvent = defineEvent({ name: "approval", validator: v.object({ approved: v.boolean() }), }); // In the workflow: const approval = await step.awaitEvent(approvalEvent); // From a mutation: const value = { approved: true }; await sendEvent(ctx, components.workflow, { ...approvalEvent, workflowId, value, }); ``` See [`example/convex/userConfirmation.ts`](./example/convex/userConfirmation.ts) for a full approval flow built this way. Note: this is just a convenience to create a typed { event, validator } pair. #### Waiting for dynamically created events by ID You can also dynamically create an event with `createEvent`: ```ts import { createEvent } from "@convex-dev/workflow"; const eventId = await createEvent(ctx, components.workflow, { name: "userResponse", workflowId, }); ``` Then wait for it by ID in the workflow: ```ts await step.awaitEvent({ id: eventId }); ``` This works well when there are dynamically defined events, for instance a tool that is waiting for a response from a user. You would save the eventId somewhere to be able to send the event later with `sendEvent`: ```ts await sendEvent(ctx, components.workflow, { id: eventId }); ``` Similar to named events, you can also send a value or error with the event. See [`example/convex/passingSignals.ts`](./example/convex/passingSignals.ts) for a complete example of creating events, passing their IDs around, and sending signals. ### Running nested workflows with `step.runWorkflow` Use `step.runWorkflow` to run another workflow as a single step in the current one. The parent workflow waits for the nested workflow to finish and receives its return value: `const result = await step.runWorkflow(internal.example.childWorkflow, { args });` You can also specify scheduling options like `{ runAfter: 5000 }` to delay the nested workflow. See [`example/convex/nestedWorkflow.ts`](./example/convex/nestedWorkflow.ts) for a complete parent/child workflow example. To associate the child workflow with the parent in your own tables, you can pass the `step.workflowId` to the child workflow as an argument, and/or return the child's workflowId to the parent. The status of the parent workflow will include any active child workflowIds. ### Listing workflows and steps Use `list` to get a paginated list of all workflows. ```ts import { list, listByName, listSteps } from "@convex-dev/workflow"; ``` ```ts await list(ctx, components.workflow, { order: "asc" }); ``` Use `listByName` to get a paginated list of workflows matching a specific name. ```ts await listByName(ctx, components.workflow, "file/folder:function", { order: "desc", }); ``` Both accept paginationOpts, such as `{ numItems: 50, cursor: null }` to get the first 50 items, or with a continue cursor from a previous call to paginate through them all. Use `listSteps` with a workflow's ID to get a paginated list of the steps in that workflow run. ```ts await listSteps(ctx, components.workflow, workflowId); ``` ## Tips and troubleshooting ### Circular dependencies Having the return value of workflows depend on other Convex functions can lead to circular dependencies due to the `internal.foo.bar` way of specifying functions. The way to fix this is to explicitly type the return value of the workflow. When in doubt, add return types to more `handler` functions, like this: ```diff export const supportAgentWorkflow = workflow.define({ args: { prompt: v.string(), userId: v.string(), threadId: v.string() }, + handler: async (step, { prompt, userId, threadId }): Promise<string> => { // ... }, }); // And regular functions too: export const myFunction = action({ args: { prompt: v.string() }, + handler: async (ctx, { prompt }): Promise<string> => { // ... }, }); ``` ### Tip: More concise workflows To avoid the noise of `internal.foo.*` syntax, you can use a variable. For instance, if you define all your steps in `convex/steps.ts`, you can do this: ```diff const s = internal.steps; export const myWorkflow = workflow.define({ args: { prompt: v.string() }, handler: async (step, args): Promise<string> => { + const result = await step.runAction(s.myAction, args); return result; }, }); ``` ## Limitations Here are a few limitations to keep in mind: - Steps can only take in and return a total of _1 MB_ of data within a single workflow execution. If you run into journal size limits, you can work around this by storing results in the database or file storage from your step functions and passing IDs around within the the workflow. - The workflow body is internally a mutation, with each step's return value read from the database on each subsequent step. As a result, the limits for a mutation apply and limit the number and size of steps you can perform (including the workflow state overhead). There is currently an 8MiB limit imposed on the journal size, to stay well within the mutation bounds. See more about mutation limits here: https://docs.convex.dev/production/state/limits#transactions - If you need to use side effects like `fetch` or use crypto.subtle, you'll need to do that in a step, not in the workflow definition. - `Math.random` in the handler itself is seeded per workflow for determinism and not suitable for cryptographic use. It is, however, useful for sharding, jitter, and other pseudo-random applications. - If the implementation of the workflow meaningfully changes (steps added, removed, or reordered) then it will fail with a determinism violation. The implementation should stay stable for the lifetime of active workflows. See [this issue](https://github.com/get-convex/workflow/issues/35) for ideas on how to make this better. Open a [GitHub issue](https://github.com/get-convex/workflow/issues) with any feedback or bugs you find. <!-- END: Include on https://convex.dev/components -->