Skip to content

Commit 6d2439f

Browse files
committed
rules of steps
1 parent 3f6db1d commit 6d2439f

File tree

3 files changed

+246
-7
lines changed

3 files changed

+246
-7
lines changed

src/content/docs/workflows/build/rules-of-steps.mdx

Lines changed: 176 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,193 @@ title: Rules of Steps
33
pcx_content_type: concept
44
sidebar:
55
order: 10
6-
76
---
87

98
A Workflow step is self-contained, individually retriable component of a Workflow. Steps may emit (optional) state that allows a Workflow to persist and continue from that step, even if a Workflow fails due to a network or infrastructure issue. A Workflow is comprised of one or more steps.
109

10+
This is a small guidebook on how to build more resilient and correct Workflows.
11+
12+
### Ensure API/Binding calls are idempotent
13+
14+
Because a step might be retried multiple times, your steps should (ideally) be idempotent. For context, idempotency is a logical property where the operation (in this case a step),
15+
can be applied multiple times without changing the result beyond the intial application.
16+
17+
As an example, let's assume you have a Workflow that charges your customers and you really don't want to charge them twice by accident, before charging them, you should
18+
check if they were already charged:
19+
20+
```ts
21+
export class MyWorkflow extends Workflow<Env, Params> {
22+
async run(events: WorkflowEvent[], step: WorkflowStep) {
23+
const customer_id = 123456;
24+
// ✅ Good: Non-idempotent API/Binding calls are always done **after** checking if the operation is
25+
// still needed.
26+
await step.do(
27+
`charge ${customer_id} for it's montly subscription`,
28+
async () => {
29+
// API call to check if customer was already charged
30+
const subscription = await fetch(
31+
`https://payment.processor/subscriptions/${customer_id}`,
32+
).then((res) => res.json());
33+
34+
// return early if the customer was already charged, this can happen if the destination service dies
35+
// in the middle of the request but still commits it, or if the Workflows Engine restarts.
36+
if (subscription.charged) {
37+
return;
38+
}
39+
40+
// non-idempotent call, this operation can fail and retry but still commit in the payment
41+
// processor - which means that, on retry, it would mischarge the customer again if the above checks
42+
// were not in place.
43+
return await fetch(
44+
`https://payment.processor/subscriptions/${customer_id}`,
45+
{
46+
method: "POST",
47+
body: JSON.stringify({ amount: 10.0 }),
48+
},
49+
);
50+
},
51+
);
52+
}
53+
}
54+
```
55+
56+
:::note
57+
58+
Guaranteeing idempotency might be optional in your specific use-case and implementaion, although we recommend it to always try to guarantee it.
59+
60+
:::
61+
1162
### Make your steps granular
1263

13-
TODO - step is a transaction, should be a self-contained logic. If you have multiple API calls, seperate them into their own steps.
64+
Steps should be as self-contained as possible, this allows your own logic to be more durable in case of failures in third-party APIs, network errors, and so on.
65+
You can also think of it as a transaction, or a unit of work.
1466

15-
### Ensure API calls are idempotent
67+
- ✅ Minimize the number of API/binding calls per step (unless you need multiple calls to prove idempotency).
1668

17-
TODO
69+
```ts
70+
export class MyWorkflow extends Workflow<Env, Params> {
71+
async run(events: WorkflowEvent[], step: WorkflowStep) {
72+
// ✅ Good: Unrelated API/Binding calls are self-contained, so that in case one of them fails
73+
// it can retry them individually. It also has an extra advantage: you can control retry or
74+
// timeout policies for each granular step - you might not to want to overload http.cat in
75+
// case of it being down.
76+
const httpCat = await step.do("get cutest cat from KV", async () => {
77+
return await env.KV.get("cutest-http-cat");
78+
});
79+
80+
const image = await step.do("fetch cat image from http.cat", async () => {
81+
return await fetch(`https://http.cat/${httpCat}`);
82+
});
83+
}
84+
}
85+
```
86+
87+
Otherwise your entire workflow might not be as durable as you might think, and encounter into some undefined behaviour and you can avoid them by:
88+
89+
- 🔴 Do not encapsulate your entire logic in one single step.
90+
- 🔴 Do not call seperate services in the same step (unless you need it to prove idempotency)
91+
- 🔴 Do not make too many service calls in the same step (unless you need it to prove idempotency)
92+
- 🔴 Do not do too much CPU-intensive work inside of a single step - sometimes engine might have to restart and it will start over from that step.
93+
94+
```ts
95+
export class MyWorkflow extends Workflow<Env, Params> {
96+
async run(events: WorkflowEvent[], step: WorkflowStep) {
97+
// 🔴 Bad: you're calling two seperate services from within the same step. This might cause
98+
// some extra calls to the first service in case the second one fails, and in some cases, makes
99+
// the step non-idempotent altogether
100+
const image = await step.do("get cutest cat from KV", async () => {
101+
const httpCat = await env.KV.get("cutest-http-cat");
102+
return fetch(`https://http.cat/${httpCat}`);
103+
});
104+
}
105+
}
106+
```
18107

19108
### Don't rely on state outside of a step
20109

21-
TODO
110+
Sometimes, our Engine will hibernate and lose all in-memory state - this will happen when engine detects that there's no pending work and can hibernate
111+
until it needs to wake-up (because of a sleep, retry or event). This means that you can't do something like this:
112+
113+
```ts
114+
function getRandomInt(min, max) {
115+
const minCeiled = Math.ceil(min);
116+
const maxFloored = Math.floor(max);
117+
return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled); // The maximum is exclusive and the minimum is inclusive
118+
}
119+
120+
export class MyWorkflow extends Workflow<Env, Params> {
121+
async run(events: WorkflowEvent[], step: WorkflowStep) {
122+
// 🔴 Bad: `imageList` will be not persisted across engine's lifetimes. Which means that after hibernation,
123+
// `imageList` will be empty again, even though the following two steps have already ran.
124+
const imageList: string[] = [];
125+
126+
await step.do("get first cutest cat from KV", async () => {
127+
const httpCat = await env.KV.get("cutest-http-cat-1");
128+
129+
imageList.append(httpCat);
130+
});
131+
132+
await step.do("get second cutest cat from KV", async () => {
133+
const httpCat = await env.KV.get("cutest-http-cat-2");
134+
135+
imageList.append(httpCat);
136+
});
137+
138+
// A long sleep can (and probably will) hibernate the engine which means that the first engine lifetime ends here
139+
await step.sleep("💤💤💤💤", "3 hours");
140+
141+
// When this runs, it will be on the second engine lifetime - which means `imageList` will be empty.
142+
await step.do(
143+
"choose a random cat from the list and download it",
144+
async () => {
145+
const randomCat = imageList.at(getRandomInt(0, imageList.length));
146+
// this will fail since `randomCat` is undefined because `imageList` is empty
147+
return await fetch(`https://http.cat/${randomCat}`);
148+
},
149+
);
150+
}
151+
}
152+
```
153+
154+
Instead, you should build top-level state exclusively comprised of `step.do` returns:
155+
156+
```ts
157+
function getRandomInt(min, max) {
158+
const minCeiled = Math.ceil(min);
159+
const maxFloored = Math.floor(max);
160+
return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled); // The maximum is exclusive and the minimum is inclusive
161+
}
162+
163+
export class MyWorkflow extends Workflow<Env, Params> {
164+
async run(events: WorkflowEvent[], step: WorkflowStep) {
165+
// ✅ Good: imageList state is exclusively comprised of step returns - this means that in the event of
166+
// multiple engine lifetimes, imageList will be built accordingly
167+
const imageList: string[] = await Promise.all([
168+
step.do("get first cutest cat from KV", async () => {
169+
return await env.KV.get("cutest-http-cat-1");
170+
}),
171+
172+
step.do("get second cutest cat from KV", async () => {
173+
return await env.KV.get("cutest-http-cat-2");
174+
}),
175+
]);
176+
177+
// A long sleep can (and probably will) hibernate the engine which means that the first engine lifetime ends here
178+
await step.sleep("💤💤💤💤", "3 hours");
179+
180+
// When this runs, it will be on the second engine lifetime - but this time, imageList will contain
181+
// the two most cutest cats
182+
await step.do(
183+
"choose a random cat from the list and download it",
184+
async () => {
185+
const randomCat = imageList.at(getRandomInt(0, imageList.length));
186+
// this will eventually succeed since `randomCat` is defined
187+
return await fetch(`https://http.cat/${randomCat}`);
188+
},
189+
);
190+
}
191+
}
192+
```
22193

23194
### Set sensible retry parameters
24195

src/content/docs/workflows/get-started/cli-quick-start.mdx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,13 +113,75 @@ TODO - basic trigger steps
113113
### CLI
114114

115115
```sh
116+
# Trigger a Workflow from the CLI, and pass (optional) parameters as an event to the Workflow.
116117
npx wrangler@latest workflows trigger workflows-tutorial --params={"hello":"world"}
117118
```
118119

120+
Refer to the [events and parameters documentation](/workflows/build/events-and-parameters/) to understand how events are passed to Workflows.
121+
119122
### Worker binding
120123

121124
TODO - trigger from a Worker
122125

126+
```toml title="wrangler.toml"
127+
[[workflows]]
128+
# name of your workflow
129+
name = "workflows-starter"
130+
# binding name env.MYWORKFLOW
131+
binding = "MY_WORKFLOW"
132+
# this is class that extends the Workflow class in src/index.ts
133+
class_name = "MyWorkflow"
134+
# script_name is required during for the beta.
135+
# Must match the "name" of your Worker at the top of wrangler.toml
136+
script_name = "workflows-starter"
137+
```
138+
139+
You can then invoke the methods on this binding directly from your Worker script. The `Workflow` type has methods for:
140+
141+
* `create(newId, params)` - creating (triggering) a new instance of the Workflow, with optional
142+
* `get(id)`- retrieve a Workflow instance by ID
143+
* `status()` - get the current status of a unique Workflow instance.
144+
145+
For example, the following Worker will fetch the status of an existing Workflow instance by ID (if supplied), else it will create a new Workflow instance and return its ID:
146+
147+
```ts title="src/index.ts"
148+
// Import the Workflow definition
149+
import { WorkflowEntrypoint, WorkflowEvent, WorkflowStep } from "cloudflare:workflows"
150+
151+
interface Env {
152+
// Matches the binding definition in your wrangler.toml
153+
MY_WORKFLOW: Workflow;
154+
}
155+
156+
export default {
157+
async fetch(req: Request, env: Env) {
158+
//
159+
const instanceId = new URL(req.url).searchParams.get("instanceId")
160+
161+
// If an ?instanceId=<id> query parameter is provided, fetch the status
162+
// of an existing Workflow by its ID.
163+
if (instanceId) {
164+
let instance = await env.MYWORKFLOW.get(id);
165+
return Response.json({
166+
status: await instance.status(),
167+
});
168+
}
169+
170+
// Else, create a new instance of our Workflow, passing in any (optional) params
171+
// and return the ID.
172+
const newId = await crypto.randomUUID();
173+
let instance = await env.MYWORKFLOW.create(newId, {});
174+
return Response.json({
175+
id: instance.id,
176+
details: await instance.status(),
177+
});
178+
179+
return Response.json({ result });
180+
},
181+
};
182+
```
183+
184+
Refer to the [triggering Workflows] documentation for how to trigger a Workflow from other Workers handler functions.
123185

124186
## 4. Managing Workflows
125187

src/content/docs/workflows/get-started/guide.mdx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ binding = "MY_WORKFLOW"
164164
# this is class that extends the Workflow class in src/index.ts
165165
class_name = "MyWorkflow"
166166
# script_name is required during for the beta.
167-
# Must match the "name" of your Worker at the top of wrangler.toml
167+
# Must match the "name" of your Worker at the top of wrangler.toml
168168
script_name = "workflows-starter"
169169
```
170170

@@ -228,9 +228,15 @@ In a production application, you might choose to put authentication in front of
228228

229229
### Review your Workflow code
230230

231+
:::note
232+
233+
This is the full contents of the `src/index.ts` file pulled down when you used the `cloudflare/workflows-starter` template at the beginning of this guide.
234+
235+
:::
236+
231237
Before you deploy, you can review the full Workflows code and the `fetch` handler that will allow you to trigger your Workflow over HTTP:
232238

233-
```ts
239+
```ts title="src/index.ts"
234240
import { WorkflowEntrypoint, WorkflowStep, WorkflowEvent } from 'cloudflare:workers';
235241

236242
type Env = {

0 commit comments

Comments
 (0)