Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"pages": [
"docs/sleeping",
"docs/parallel-steps",
"docs/dynamic-steps",
"docs/retries",
"docs/type-safety",
"docs/versioning",
Expand Down
64 changes: 64 additions & 0 deletions packages/docs/docs/dynamic-steps.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
title: Dynamic Steps
description: Run a variable number of steps based on runtime data
---

Sometimes you don't know how many steps a workflow needs until it runs. You
might need to fetch data for each item in a list, process rows from a query,
or fan out across a set of IDs from an API response. OpenWorkflow handles
this — you can create steps inside loops and maps, as long as each step has a
deterministic name.

## Basic Pattern

Map over your data and create a step per item using `Promise.all`:

```ts
const results = await Promise.all(
input.items.map((item) =>
step.run({ name: `fetch-data:${item.id}` }, async () => {
return await thirdPartyApi.fetch(item.id);
}),
),
);
```

Each step is individually memoized. If the workflow restarts, completed steps
return their cached results and only the remaining steps re-execute.

The most important rule: **step names must be deterministic across replays**.
Use a stable identifier from the data itself — like a database ID, a slug, or
a unique key:

```ts
// Good — stable ID from the data
step.run({ name: `process-order:${order.id}` }, ...)
step.run({ name: `send-email:${user.email}` }, ...)

// Bad — non-deterministic, different on every run
step.run({ name: `task-${Date.now()}` }, ...)
step.run({ name: `task-${crypto.randomUUID()}` }, ...)
```

<Warning>
Non-deterministic names (timestamps, random values, request IDs) break replay.
Completed steps won't be found in history, causing them to re-execute.
</Warning>

### Falling Back to Array Indexes

When no stable ID exists, you can use the array index:

```ts
const results = await Promise.all(
input.items.map((item, index) =>
step.run({ name: `fetch-data:${index}` }, async () => {
return await thirdPartyApi.fetch(item.lookupKey);
}),
),
);
```

This is safe only if the array order is identical between the original run and
any replay. If the order changes, cached results get returned for the wrong
items.
24 changes: 24 additions & 0 deletions packages/docs/docs/steps.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ await step.run({ name: "step-1" }, ...);
await step.run({ name: "step-2" }, ...);
```

If you need to create a dynamic number of steps from runtime data (like
mapping over an array), see [Dynamic Steps](/docs/dynamic-steps).

<Warning>
Changing step names after workflows are in-flight can cause replay errors.
Completed steps won't be found in the history, causing them to re-execute. To
Expand Down Expand Up @@ -184,3 +187,24 @@ await step.run({ name: "do-everything" }, async () => {
If an operation has no side effects and is fast to compute, consider whether
it really needs to be a step. Pure computations can happen outside of steps.
</Tip>

## Large Payloads

Every step result is persisted to the database. If a step returns a large
payload, your workflow history can become heavy — especially when you have many
steps.

A good pattern is to offload large data to external storage and return only a
reference:

```ts
const data = await step.run({ name: "fetch-report" }, async () => {
const report = await analyticsApi.generate(input.reportId);

const objectKey = `reports/${input.reportId}.json`;
await objectStore.put(objectKey, JSON.stringify(report));

// Store only the reference, not the full report
return { reportId: input.reportId, objectKey };
});
```