Rules of Workflows·续1
// 🔴 Bad: using non-deterministic functions outside of steps // this will produce different results if the instance has to restart, different runs of the same instance // might go through different paths const badRandom = Math.random();
if (badRandom > 0) { // do some stuff }
// ⚠️ Warning: This log may happen many times console.log("This might be logged more than once");
await step.do("do some stuff and have a log for when it runs", async () => { // do some stuff
// this log will only appear once console.log("successfully did stuff"); });
// ✅ Good: wrap non-deterministic function in a step // after running successfully will not run again const goodRandom = await step.do("create a random number", async () => { return Math.random(); });
// ✅ Good: calls that have no side effects can be done outside of steps // For Hyperdrive, create the connection inside each step instead of here. const db = createDBConnection(this.env.DB_URL, this.env.DB_TOKEN);
// ✅ Good: run functions with side effects inside of a step // after running successfully will not run again const goodInstance = await step.do( "good step that returns state", async () => { const instance = await this.env.ANOTHER_WORKFLOW.create();
return instance; }, ); } }
*index.tsts*
```ts
export class MyWorkflow extends WorkflowEntrypoint {
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
// 🔴 Bad: creating instances outside of steps
// This might get called more than once creating more instances than expected
const badInstance = await this.env.ANOTHER_WORKFLOW.create();
// 🔴 Bad: using non-deterministic functions outside of steps
// this will produce different results if the instance has to restart, different runs of the same instance
// might go through different paths
const badRandom = Math.random();
if (badRandom > 0) {
// do some stuff
}
// ⚠️ Warning: This log may happen many times
console.log("This might be logged more than once");
await step.do("do some stuff and have a log for when it runs", async () => {
// do some stuff
// this log will only appear once
console.log("successfully did stuff");
});
// ✅ Good: wrap non-deterministic function in a step
// after running successfully will not run again
const goodRandom = await step.do("create a random number", async () => {
return Math.random();
});
// ✅ Good: calls that have no side effects can be done outside of steps
// For Hyperdrive, create the connection inside each step instead of here.
const db = createDBConnection(this.env.DB_URL, this.env.DB_TOKEN);
// ✅ Good: run functions with side effects inside of a step
// after running successfully will not run again
const goodInstance = await step.do(
"good step that returns state",
async () => {
const instance = await this.env.ANOTHER_WORKFLOW.create();
return instance;
},
);
}
}
Do not mutate your incoming events
The event passed to your Workflow's run method is immutable: changes you make to the event are not persisted across steps and/or Workflow restarts.
index.jsjs
export class MyWorkflow extends WorkflowEntrypoint {
async run(event, step) {
// 🔴 Bad: Mutating the event
// This will not be persisted across steps and `event.payload` will
// take on its original value.
await step.do("bad step that mutates the incoming event", async () => {
let userData = await this.env.KV.get(event.payload.user);
event.payload = userData;
});
// ✅ Good: persist data by returning it as state from your step
// Use that state in subsequent steps
let userData = await step.do("good step that returns state", async () => {
return await this.env.KV.get(event.payload.user);
});
let someOtherData = await step.do(
"following step that uses that state",
async () => {
// Access to userData here
// Will always be the same if this step is retried
},
);
}
}
index.tsts
interface MyEvent {
user: string;
data: string;
}
export class MyWorkflow extends WorkflowEntrypoint {
async run(event: WorkflowEvent<MyEvent>, step: WorkflowStep) {
// 🔴 Bad: Mutating the event
// This will not be persisted across steps and `event.payload` will
// take on its original value.
await step.do("bad step that mutates the incoming event", async () => {
let userData = await this.env.KV.get(event.payload.user);
event.payload = userData;
});
// ✅ Good: persist data by returning it as state from your step
// Use that state in subsequent steps
let userData = await step.do("good step that returns state", async () => {
return await this.env.KV.get(event.payload.user);
});
let someOtherData = await step.do(
"following step that uses that state",
async () => {
// Access to userData here
// Will always be the same if this step is retried
},
);
}
}
Name steps deterministically
Steps should be named deterministically (that is, not using the current date/time, randomness, etc). This ensures that their state is cached, and prevents the step from being rerun unnecessarily. Step names act as the "cache key" in your Workflow.
index.jsjs
export class MyWorkflow extends WorkflowEntrypoint {
async run(event, step) {
// 🔴 Bad: Naming the step non-deterministically prevents it from being cached
// This will cause the step to be re-run if subsequent steps fail.
await step.do(`step #1 running at: ${Date.now()}`, async () => {
let userData = await this.env.KV.get(event.payload.user);
// Do not mutate event.payload
event.payload = userData;
});
// ✅ Good: give steps a deterministic name.
// Return dynamic values in your state, or log them instead.
let state = await step.do("fetch user data from KV", async () => {
let userData = await this.env.KV.get(event.payload.user);
console.log(`fetched at ${Date.now()}`);
return userData;
});
// ✅ Good: steps that are dynamically named are constructed in a deterministic way.
// In this case, `catList` is a step output, which is stable, and `catList` is
// traversed in a deterministic fashion (no shuffles or random accesses) so,
// it's fine to dynamically name steps (e.g: create a step per list entry).
let catList = await step.do("get cat list from KV", async () => {
return await this.env.KV.get("cat-list");
});
for (const cat of catList) {
await step.do(`get cat: ${cat}`, async () => {
return await this.env.KV.get(cat);
});
}
}
}
index.tsts
export class MyWorkflow extends WorkflowEntrypoint {
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
// 🔴 Bad: Naming the step non-deterministically prevents it from being cached
// This will cause the step to be re-run if subsequent steps fail.
await step.do(`step #1 running at: ${Date.now()}`, async () => {
let userData = await this.env.KV.get(event.payload.user);
// Do not mutate event.payload
event.payload = userData;
});
// ✅ Good: give steps a deterministic name.
// Return dynamic values in your state, or log them instead.
let state = await step.do("fetch user data from KV", async () => {
let userData = await this.env.KV.get(event.payload.user);
console.log(`fetched at ${Date.now()}`);
return userData;
});
// ✅ Good: steps that are dynamically named are constructed in a deterministic way.
// In this case, `catList` is a step output, which is stable, and `catList` is
// traversed in a deterministic fashion (no shuffles or random accesses) so,
// it's fine to dynamically name steps (e.g: create a step per list entry).
let catList = await step.do("get cat list from KV", async () => {
return await this.env.KV.get("cat-list");
});
for (const cat of catList) {
await step.do(`get cat: ${cat}`, async () => {
return await this.env.KV.get(cat);
});
}
}
}
Take care with Promise.race() and Promise.any()
Workflows allows the usage steps within the Promise.race() or Promise.any() methods as a way to achieve concurrent steps execution. However, some considerations must be taken.
Due to the nature of Workflows' instance lifecycle, and given that a step inside a Promise will run until it finishes, the step that is returned during the first passage may not be the actual cached step, as steps are cached by their names.
index.jsjs
// helper sleep method
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export class MyWorkflow extends WorkflowEntrypoint {
async run(event, step) {
// 🔴 Bad: The `Promise.race` is not surrounded by a `step.do`, which may cause non-deterministic caching behavior.
const race_return = await Promise.race([
step.do("Promise first race", async () => {
await sleep(1000);
return "first";
}),
step.do("Promise second race", async () => {
return "second";
}),
]);
await step.sleep("Sleep step", "2 hours");
return await step.do("Another step", async () => {
// This step will return `first`, even though the `Promise.race` first returned `second`.
return race_return;
});
}
}
index.tsts
// helper sleep method
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export class MyWorkflow extends WorkflowEntrypoint {
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
// 🔴 Bad: The `Promise.race` is not surrounded by a `step.do`, which may cause non-deterministic caching behavior.
const race_return = await Promise.race([
step.do("Promise first race", async () => {
await sleep(1000);
return "first";
}),
step.do("Promise second race", async () => {
return "second";
}),
]);
await step.sleep("Sleep step", "2 hours");
return await step.do("Another step", async () => {
// This step will return `first`, even though the `Promise.race` first returned `second`.
return race_return;
});
}
}
To ensure consistency, we suggest to surround the Promise.race() or Promise.any() within a single step.do(), as this will ensure caching consistency across multiple passages. Avoid nesting step.do() calls inside the race — use plain async functions so that only the outer step is cached.
index.jsjs
// helper sleep method
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export class MyWorkflow extends WorkflowEntrypoint {
async run(event, step) {
// ✅ Good: The `Promise.race` is surrounded by a single `step.do`, ensuring deterministic
// caching behavior, and it does not nest `step.do` calls inside the race.
const race_return = await step.do("race step", async () => {
return await Promise.race([
(async () => {
await sleep(1000);
return "first";
})(),
(async () => {
return "second";
})(),
]);
});
await step.sleep("Sleep step", "2 hours");
return await step.do("Another step", async () => {
// This step will return `second` because the `Promise.race` was surrounded by the `step.do` method.
return race_return;
});
}
}
index.tsts
// helper sleep method
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export class MyWorkflow extends WorkflowEntrypoint {
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
// ✅ Good: The `Promise.race` is surrounded by a single `step.do`, ensuring deterministic
// caching behavior, and it does not nest `step.do` calls inside the race.
const race_return = await step.do("race step", async () => {
return await Promise.race([
(async () => {
await sleep(1000);
return "first";
})(),
(async () => {
return "second";
})(),
]);
});
await step.sleep("Sleep step", "2 hours");
return await step.do("Another step", async () => {
// This step will return `second` because the `Promise.race` was surrounded by the `step.do` method.
return race_return;
});
}
}
Instance IDs are unique
Workflow instance IDs are unique per Workflow. The ID is the unique identifier that associates logs, metrics, state and status of a run to a specific instance, even after completion. Allowing ID re-use would make it hard to understand if a Workflow instance ID referred to an instance that run yesterday, last week or today.
It would also present a problem if you wanted to run multiple different Workflow instances with different input parameters for the same user ID, as you would immediately need to determine a new ID mapping.
If you need to associate multiple instances with a specific user, merchant or other "customer" ID in your system, consider using a composite ID or using randomly generated IDs and storing the mapping in a database like D1.
index.jsjs
// This is in the same file as your Workflow definition
export default {
async fetch(req, env) {
// 🔴 Bad: Use an ID that isn't unique across future Workflow invocations
let userId = getUserId(req); // Returns the userId
let badInstance = await env.MY_WORKFLOW.create({
id: userId,
params: payload,
});
// ✅ Good: use an ID that is unique
// e.g. a transaction ID, order ID, or task ID are good options
let instanceId = getTransactionId(); // e.g. assuming transaction IDs are unique
// or: compose a composite ID and store it in your database
// so that you can track all instances associated with a specific user or merchant.
instanceId = `${getUserId(req)}-${crypto.randomUUID().slice(0, 6)}`;
let { result } = await addNewInstanceToDB(userId, instanceId);
let goodInstance = await env.MY_WORKFLOW.create({
id: instanceId,
params: payload,
});
return Response.json({
id: goodInstance.id,
details: await goodInstance.status(),
});
},
};
index.tsts
```ts // This is in the same file as your Workflow definition export default { async fetch(req: Request, env: Env): Promise<Response> { // 🔴 Bad: Use an ID that isn't unique across future Workflow invocations let userId = getUserId(req); // Returns the userId let badInstance = await env.MY_WORKFLOW.create({ id: userId, params: payload, });
