Durable workflows
workflowsWrite a flow that takes minutes or weeks as one async function, with each step journaled and never run twice after a crash, restart or deploy.
workflowsFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
workflows runs a multi-step flow as one async function, journaling each step so it resumes after a crash, restart or deploy. Use it when work has several side effects or must wait.
A builder loads a user, sends a welcome email, waits three days, waits for the user to activate, and asks a person to approve credits. Finished steps are not run again. For one side effect in the background, use jobs.
At a glance
workflows:
driver: db
retries: { max: 3, backoff: exponential, initial: 1s }
max-step-result: 256KB
lease: 30s
keep: 30dimport { workflow, step, sleep } from "mainsdk/workflows";
export const welcome = workflow("welcome", { version: 1 }, async ({ userId }: { userId: string }) => {
await step("send", () => email.send({ to: userId, template: "welcome" }));
await sleep("3d");
await step("nudge", () => notify(userId, { template: "come-back" }));
});app/workflows/onboarding.test.ts
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { workflow, step, sleep, waitFor } from "mainsdk/workflows";
import { s } from "mainsdk/schema";
const sent: string[] = [];
const onboarding = workflow(
"onboarding",
{ version: 1, input: s.object({ userId: s.string() }) },
async ({ userId }) => {
await step("welcome", () => { sent.push(`welcome:${userId}`); return { sent: true }; });
await sleep("3d");
const activated = await waitFor("user.activated", { match: { userId }, timeout: "7d" });
return { activated: activated !== null };
},
);
describe("onboarding", () => {
it("workflows AC1 sends the welcome once, sleeps, then finishes when the user activates", async () => {
const app = createTestApp();
const { runId } = await app.workflows.start(onboarding, { userId: "u1" }, { idempotencyKey: "onboarding:u1" });
await app.clock.tick("1m");
assert.equal((await app.workflows.get(runId)).state, "sleeping");
await app.clock.tick("3d");
assert.equal((await app.workflows.get(runId)).state, "waiting");
await app.events.publish("user.activated", { userId: "u1" });
await app.clock.tick("1m");
const run = await app.workflows.get(runId);
assert.equal(run.state, "completed");
assert.deepEqual(run.output, { activated: true });
assert.deepEqual(sent, ["welcome:u1"]);
});
});Drivers
5 drivers. Select one to see its line in main.yaml.
workflows:
driver: dbChange this one line to swap providers; your calls stay the same.Your database integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-WORKFLOW-0009 | Checkmainsdk check, before deploy | A duration is not a valid <n><s|m|h|d> value, or is over 365d |
MAIN-WORKFLOW-0010 | Checkmainsdk check, before deploy | A clock, random or network call (Date.now, new Date(), Math.random, crypto.randomUUID, http.*, fetch) in a workflow function outside a step |
MAIN-WORKFLOW-0011 | Checkmainsdk check, before deploy | The local driver in production |
MAIN-WORKFLOW-0012 | Checkmainsdk check, before deploy | Two workflow definitions with the same name and version |
MAIN-WORKFLOW-0001 | RuntimeError responses and logs | step, sleep, waitFor or approve called outside a workflow run (500) |
MAIN-WORKFLOW-0002 | RuntimeError responses and logs | On resume, the code reached a step the journal doesn't have in that place (code changed under a live version); the run stops as stuck |
MAIN-WORKFLOW-0003 | RuntimeError responses and logs | No registered code for the run's workflow version; the run stops as stuck |
MAIN-WORKFLOW-0004 | RuntimeError responses and logs | A step result or workflow input is not JSON, or is larger than max-step-result |
MAIN-WORKFLOW-0005 | RuntimeError responses and logs | A step failed after its last retry; the run is failed |
MAIN-WORKFLOW-0006 | RuntimeError responses and logs | No run with that id in the current tenant (404) |
MAIN-WORKFLOW-0007 | RuntimeError responses and logs | The journal store is unreachable, so the run was not started (503, retryable) |
MAIN-WORKFLOW-0008 | RuntimeError responses and logs | Cancel of a run that has already finished (409) |
MAIN-WORKFLOW-0013 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| workflow() and step() with a journal | Phase 1 | 0.3 | workflows#contract |
| Resume from the journal after a crash or deploy | Phase 1 | 0.3 | workflows#replay |
| Finished steps return their journaled result | Phase 1 | 0.3 | workflows#idempotent-steps |
| Per-step retries with backoff | Phase 1 | 0.3 | workflows#retries |
| sleep and waitFor without holding a worker | Phase 1 | 0.3 | workflows#timers-events |
| approve for a person's yes | Phase 1 | 0.3 | workflows#approve |
| Versioned runs and stuck detection | Phase 1 | 0.3 | workflows#versions |
| Cancel and inspect runs | Phase 1 | 0.3 | workflows#cancel |
| Run history in mainsdk logs | Phase 1 | 0.3 | workflows#visibility |
| Tenant isolation for runs | Phase 1 | 0.3 | workflows#tenant |
| Database driver, with local and file for development | Phase 1 | 0.3 | workflows#drivers |
| Checks for clocks, randomness and network calls outside steps | Phase 1 | 0.3 | workflows#checks |
Quick start
import { workflow, step, sleep, waitFor } from "mainsdk/workflows";
import { s } from "mainsdk/schema";
// Your own code. Steps return JSON.
declare function loadUser(userId: string): Promise<{ id: string; email: string }>;
declare function sendWelcome(email: string): Promise<{ sent: boolean }>;
declare function sendNudge(email: string): Promise<{ sent: boolean }>;
export const onboarding = workflow(
"onboarding",
{ version: 1, input: s.object({ userId: s.string() }) },
async ({ userId }) => {
const user = await step("load-user", () => loadUser(userId)); // your code; returns JSON
await step("welcome", () => sendWelcome(user.email)); // runs once, even after a crash
await sleep("3d"); // parks the run; holds no worker
const activated = await waitFor("user.activated", { match: { userId }, timeout: "7d" });
if (!activated) await step("nudge", () => sendNudge(user.email));
return { activated: activated !== null };
},
);Start a run from anywhere in the app:
import { workflows, type WorkflowDef } from "mainsdk/workflows";
declare const onboarding: WorkflowDef<{ userId: string }>; // exported from app/workflows/onboarding.ts
declare const user: { id: string }; // the person who just signed up
const { runId } = await workflows.start(onboarding, { userId: user.id }, { idempotencyKey: `onboarding:${user.id}` });start writes the run and returns its runId; no step runs first. The same idempotencyKey returns the same run. A workflow function must be deterministic outside its steps: put every clock, random and network call inside a step. On resume the function runs again from the top and each finished step returns its journaled result.
Configuration
workflows:
driver: db # db | file | local
retries: { max: 3, backoff: exponential, initial: 1s }
max-step-result: 256KB # JSON size of one step result and of a run's input
lease: 30s # how long a worker holds a run before another may take it
keep: 30d # finished runs are deleted after this| Key | What it does |
|---|---|
driver | Where the journal lives. db stores it in the app's database and is the default when the app has a db section. file keeps it under .mainsdk/ and is the default otherwise; local keeps it in process |
retries | The default retry policy for each step (3 retries, exponential, 1s to start). A step can override it |
max-step-result | The largest JSON a step may return (default 256KB) |
lease | How long a crashed worker holds a run before another takes it (default 30s) |
keep | How long finished runs stay in the journal (default 30d) |
A workflow name matches ^[a-z0-9][a-z0-9._-]{0,63}$ and its version is a positive whole number (MAIN-WORKFLOW-0013 otherwise). A duration is <n><s|m|h|d> and at most 365 days.
What you get
- Each step's result is journaled, so a flow resumes at the step it reached and finished steps never run twice.
- Per-step retries with backoff, set once in
main.yamland overridable on a step. - Waits for a duration or for an event, and approval by a person.
- The same
idempotencyKeyreturns the same run. - Inspect, list and cancel runs, and change a workflow safely by version.
Examples
Retry one step, not the whole flow
import { step } from "mainsdk/workflows";
declare function grantCredits(userId: string, credits: number): Promise<{ granted: number }>;
declare const user: { id: string };
await step("grant", () => grantCredits(user.id, 5), { retries: { max: 2 } });Only grant retries, with backoff: "exponential" unless you pass "fixed" and an initial wait. The steps before it return their journaled results. A step that fails its last attempt fails the run with MAIN-WORKFLOW-0005.
Ask a person to approve
import { approve, step } from "mainsdk/workflows";
declare function grantCredits(userId: string, credits: number): Promise<{ granted: number }>;
declare const user: { id: string };
const ok = await approve({ action: "credits.grant", risk: "write", spend: "$5", reason: "Welcome credits", timeout: "2d" });
if (ok.approved) await step("grant", () => grantCredits(user.id, 5));approve puts the request in the approval queue of agents and parks the run until a person says yes (ok.approved is true with by and approvalId), or until the timeout ({ approved: false, reason: "timeout" }). A cancelled run returns reason: "cancelled". An agent cannot approve its own request. Nothing is held while it waits. A person approves with agents.approve(approvalId, { kind: "user", id }); agents.pendingApprovals() lists what is waiting.
Wait for an event
waitFor(type, { match, timeout }) parks the run until events.publish() sends an event of that type whose data contains every match field. It returns the CloudEvent, or null at the timeout.
import { events } from "mainsdk";
await events.publish("user.activated", { userId: "u_1" }); // resumes the run that waits for this userInspect, list and cancel
import { workflows } from "mainsdk/workflows";
declare const runId: string; // from workflows.start()
const run = await workflows.get(runId); // state, currentStep, history, output, error
const waiting = await workflows.list({ name: "onboarding", state: "waiting" });
await workflows.cancel(runId, { reason: "user closed the account" });state is queued, running, sleeping, waiting, completed, failed, cancelled or stuck. history lists each step's name, kind (step, sleep, waitFor or approve), attempt and times. It never includes step results or inputs. cancel stops a run that has not finished; on a finished run it fails with MAIN-WORKFLOW-0008.
Change a workflow safely
A run keeps the version it started on. To change the steps, register a new version and keep the old one registered while runs are on it. New runs start on the highest registered version.
import { workflow, workflows } from "mainsdk/workflows";
export const digestV1 = workflow("digest", { version: 1 }, async () => { /* old steps */ });
export const digestV2 = workflow("digest", { version: 2 }, async () => { /* new steps */ });
workflows.register(digestV1); // `start` registers a definition too
workflows.register(digestV2);If the code reaches a step the journal doesn't have, the run is stuck with MAIN-WORKFLOW-0002. If no code is registered for a run's version it is stuck with MAIN-WORKFLOW-0003, and it resumes once you register it.
Errors and checks
| Code | When | Fix |
|---|---|---|
MAIN-WORKFLOW-0001 | step, sleep, waitFor or approve was called outside a workflow run | Call it inside the function you pass to workflow(), started with workflows.start() |
MAIN-WORKFLOW-0002 | On resume the code reached a step the journal doesn't have (the code changed under a live version); the run is stuck | Keep old steps in place, or start new runs on a new version |
MAIN-WORKFLOW-0003 | No registered code for the run's version; the run is stuck | Register the version |
MAIN-WORKFLOW-0004 | A step result or input is not JSON, or is over max-step-result | Return a smaller JSON value |
MAIN-WORKFLOW-0005 | A step failed after its last retry; the run is failed | Fix the cause, then start a new run |
MAIN-WORKFLOW-0006 | No run with that id in the current tenant (404) | Use the runId from workflows.start() |
MAIN-WORKFLOW-0007 | The journal could not be reached, so the run was not started (503, retryable) | Check the database, then retry |
MAIN-WORKFLOW-0008 | workflows.cancel on a run that already finished (409) | Only a run that has not finished can be cancelled |
MAIN-WORKFLOW-0009 | A duration is not valid, or is over 365 days | Use <n><s|m|h|d> up to 365d |
MAIN-WORKFLOW-0010 | A clock, random or network call in a workflow function outside a step (check) | Move it into a step |
MAIN-WORKFLOW-0011 | The local driver in production (check) | Set driver: db |
MAIN-WORKFLOW-0012 | Two definitions share a name and version | Give one a new version |
MAIN-WORKFLOW-0013 | A workflow name or version is invalid | Use a lowercase name and a positive whole version |
See check codes for the full list.
Swapping drivers
| Driver | Use it for |
|---|---|
db | Production. The journal lives in the app's database |
file, local | Development and tests. mainsdk check --env prod fails on either (MAIN-WORKFLOW-0011) |
inngest, temporal and vercel-workflow are planned as later drivers.
Testing
createTestApp runs workflows on a fake clock and an in-memory journal. tick moves the clock and lets timers fire, so a three-day sleep takes no time.
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { workflow, step, sleep, waitFor } from "mainsdk/workflows";
import { s } from "mainsdk/schema";
const sent: string[] = [];
const onboarding = workflow(
"onboarding",
{ version: 1, input: s.object({ userId: s.string() }) },
async ({ userId }) => {
await step("welcome", () => { sent.push(`welcome:${userId}`); return { sent: true }; });
await sleep("3d");
const activated = await waitFor("user.activated", { match: { userId }, timeout: "7d" });
return { activated: activated !== null };
},
);
describe("onboarding", () => {
it("workflows AC1 sends the welcome once, sleeps, then finishes when the user activates", async () => {
const app = createTestApp();
const { runId } = await app.workflows.start(onboarding, { userId: "u1" }, { idempotencyKey: "onboarding:u1" });
await app.clock.tick("1m");
assert.equal((await app.workflows.get(runId)).state, "sleeping");
await app.clock.tick("3d");
assert.equal((await app.workflows.get(runId)).state, "waiting");
await app.events.publish("user.activated", { userId: "u1" });
await app.clock.tick("1m");
const run = await app.workflows.get(runId);
assert.equal(run.state, "completed");
assert.deepEqual(run.output, { activated: true });
assert.deepEqual(sent, ["welcome:u1"]);
});
});Security and privacy
- The history records step names, kinds and times. Step results and inputs stay out of it, so personal data does not leak into logs. A step's journaled result is stored in the journal, so return IDs rather than personal data where you can.
- Runs belong to one tenant.
workflows.getandworkflows.listsee only the current tenant's runs, and every step runs inside the tenant that started the run. - Each run has one trace id, so logs from every step and every resume join up.
- An agent never approves its own step.
FAQ
Workflow or job? Use jobs.run for one side effect in the background; on failure the whole job retries from the top. Use a workflow when the work has more than one side effect or has to wait: only the failed step retries, and sleep, waitFor and approve hold no worker.
Can I use Date.now() or fetch in the function? Not outside a step. The function runs again on every resume, so a clock, random or network call outside a step gives a different answer each time and fails mainsdk check with MAIN-WORKFLOW-0010. Put it in a step and its result is journaled.
What happens on a deploy? Runs resume on the version they started on. Keep old versions registered until their runs finish.
Reference
| Call | What it does |
|---|---|
workflows.register(workflow) | Register a workflow definition, so runs can start on it and resume after a restart. workflows.start registers a definition too |
new Workflows(opts) | The engine behind workflows.*. mainsdk builds it from the workflows section of main.yaml. opts takes the run and step collections, a local, file or db driver, the tenancy, log, timers, clock, the jobs and events interfaces, agents, an idempotency store, a WorkGate and the parsed config. close() stops its timers and listeners, and a pass that is running finishes |
Next
- Events and background jobs: one side effect in the background, and the events
waitForreads. - Build a durable workflow: the same calls as a walkthrough.
- Agents: the approval queue behind
approve. - Health and status page: probes and shutdown for the app that runs the workflows.
Was this page helpful?