Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
45c284d
feat: add Workflows support
connyay Jan 28, 2026
93f3c5a
clippy?
connyay Jan 28, 2026
d19624f
backout toolchain bump
connyay Jan 29, 2026
4536c0c
fix: wrap workflow JsFuture calls with SendFuture for axum compatibility
connyay Jan 29, 2026
b270455
Merge branch 'main' into cjh-workflow
guybedford Feb 21, 2026
95c8f7e
fix: wrap workflow callback with AssertUnwindSafe for panic-unwind co…
guybedford Feb 21, 2026
23c317e
fix: use JS NonRetryableError, async bindings, and retryable callbacks
connyay Feb 26, 2026
8c2e668
Merge branch 'main' into cjh-workflow
connyay Feb 26, 2026
446dc86
test NonRetryableError
connyay Feb 26, 2026
55ab563
Simplify workflow code: fix __wf_ handler leak, deduplicate test help…
connyay Mar 12, 2026
501c3c8
test: add wait_for_event/send_event workflow integration test
connyay Mar 12, 2026
e6a264b
Merge branch 'main' into cjh-workflow
connyay Mar 31, 2026
99b52f9
add test coverage for pause(), resume(), restart(), and terminate()
connyay Apr 1, 2026
92d6778
fix: resolve CI failures for clippy and panic-unwind
guybedford Apr 1, 2026
66d6fb8
Merge branch 'main' into cjh-workflow
guybedford Apr 1, 2026
84b0120
Merge branch 'main' into cjh-workflow
guybedford Apr 1, 2026
44b2bfd
Merge branch 'main' into cjh-workflow
guybedford Apr 1, 2026
7eaee7a
fix: wrap handler! async blocks in SendFuture to satisfy axum Send bound
guybedford Apr 1, 2026
187dad2
Merge branch 'main' into cjh-workflow
guybedford Apr 1, 2026
c89bee9
pr comments
connyay Apr 2, 2026
51c2b1d
use WorkflowSleepDuration everywhere
connyay Apr 2, 2026
834db2c
thread through WorkflowStepContext
connyay Apr 2, 2026
f2f4926
feat(workflow): support step context and ReadableStream returns
connyay Apr 21, 2026
44360ea
Merge branch 'main' into cjh-workflow
connyay Apr 27, 2026
b154754
tiny comment cleanup
connyay Apr 27, 2026
30f2d4e
refactor(workflow): typed Input/Output, fix serializer round-trips
connyay Apr 27, 2026
c68b874
cleanup workflow macro rustdoc
connyay Apr 27, 2026
16dba8e
Merge branch 'main' into cjh-workflow
connyay May 31, 2026
7a88382
fmt
connyay May 31, 2026
95f95de
Merge branch 'main' into cjh-workflow
connyay Jun 28, 2026
9a09263
Add rollback, schedule, and restartopts.
connyay Jun 28, 2026
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
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ web-sys = { version = "0.3.102", features = [
"WritableStream",
"WritableStreamDefaultWriter",
] }
worker = { version = "0.8.5", path = "worker", features = ["queue", "d1", "axum", "timezone"] }
worker = { version = "0.8.5", path = "worker", features = ["queue", "d1", "axum", "timezone", "workflow"] }
worker-codegen = { path = "worker-codegen", version = "0.2.0" }
worker-macros = { version = "0.8.5", path = "worker-macros", features = ["queue"] }
worker-sys = { version = "0.8.5", path = "worker-sys", features = ["d1", "queue"] }
Expand Down
15 changes: 15 additions & 0 deletions examples/workflow/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[package]
name = "workflow-example"
version = "0.1.0"
edition = "2021"

[package.metadata.release]
release = false

[lib]
crate-type = ["cdylib"]

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
worker = { path = "../../worker", features = ["workflow"] }
69 changes: 69 additions & 0 deletions examples/workflow/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Workflows on Cloudflare Workers

Durable, multi-step [Cloudflare Workflows](https://developers.cloudflare.com/workflows/) written in Rust with the `#[workflow]` macro and `worker::WorkflowEntrypoint`.

`MyWorkflow` (in `src/lib.rs`) runs a small pipeline and exercises most of the Workflows surface:

- **Durable steps.** `step.do_(...)` and `step.do_with_config(...)` persist their return values and replay them instead of re-running.
- **Retries & backoff.** `StepConfig` / `RetryConfig` set a per-step `limit`, `delay`, and `Backoff` strategy. The `send-notification` step fails randomly to show retries in action.
- **Non-retryable errors.** `NonRetryableError` fails the instance immediately (used when the email is invalid).
- **Sleeping.** `step.sleep(...)` hibernates the instance (10 seconds here) without burning CPU.
- **Saga rollback.** `step.do_with_rollback(...)` attaches a compensation handler that the runtime invokes if a later step fails. See `reserve-inventory`.
- **Cron triggers.** When started from a `schedules` cron (see `wrangler.toml`), `event.schedule` and `event.workflow_name` are populated.

A `fetch` handler is included so you can drive the workflow over HTTP.

## Routes

| Route | Description |
|---|---|
| `POST /workflow` | Create an instance. Body: `{ "email": "...", "name": "..." }`. Returns the instance `id`. |
| `GET /workflow/{id}` | Get an instance's status, output, and error. |
| `POST /workflow/{id}/pause` | Pause a running instance. |
| `POST /workflow/{id}/resume` | Resume a paused instance. |

## Running locally

```sh
npx wrangler dev
```

`wrangler dev` runs the `[build]` command from `wrangler.toml`, which invokes `worker-build`. If you don't have it, install it first with `cargo install worker-build` (and point the build command at it, or drop the `../../target/release/` prefix).

> Saga rollbacks are a recent Workflows feature. Local development needs a current `wrangler`/`workerd`; an older toolchain will run the steps but skip the rollback.

### Walkthrough

1. Create an instance:

```sh
curl -X POST http://localhost:8787/workflow \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","name":"Ada"}'
# → {"id":"<instance-id>","message":"Workflow created"}
```

2. Poll its status (it sleeps for 10s mid-run, so give it a moment):

```sh
curl http://localhost:8787/workflow/<instance-id>
# → {"id":"...","status":"Running","error":null,"output":null}
# ...later...
# → {"id":"...","status":"Complete","output":{"message":"Workflow completed for Ada","steps_completed":4}}
```

Passing an email without an `@` makes the `validate-params` step throw a
`NonRetryableError`, so the instance ends up `Errored` instead.

3. Pause / resume while it's running (e.g. during the 10s sleep):

```sh
curl -X POST http://localhost:8787/workflow/<instance-id>/pause
curl -X POST http://localhost:8787/workflow/<instance-id>/resume
```

## Deploying

```sh
npx wrangler deploy
```
225 changes: 225 additions & 0 deletions examples/workflow/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
use serde::{Deserialize, Serialize};
use worker::*;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MyParams {
pub email: String,
pub name: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MyOutput {
pub message: String,
pub steps_completed: u32,
}

#[workflow]
pub struct MyWorkflow {
#[allow(dead_code)]
env: Env,
}

impl WorkflowEntrypoint for MyWorkflow {
type Input = MyParams;
type Output = MyOutput;

fn new(_ctx: Context, env: Env) -> Self {
Self { env }
}

async fn run(&self, event: WorkflowEvent<MyParams>, step: WorkflowStep) -> Result<MyOutput> {
console_log!(
"Workflow '{}' started with instance ID: {}",
event.workflow_name,
event.instance_id
);

// When triggered by a cron schedule (configured via `schedules` on the
// workflow binding in wrangler.toml), `event.schedule` is populated.
if let Some(schedule) = &event.schedule {
console_log!("Triggered by cron schedule: {}", schedule.cron);
}

let params = event.payload;

let email_for_validation = params.email.clone();
step.do_with_config(
"validate-params",
StepConfig {
retries: Some(RetryConfig {
limit: 3,
delay: "1 second".into(),
backoff: None,
}),
timeout: None,
sensitive: None,
},
move |ctx| {
let email = email_for_validation.clone();
async move {
console_log!(
"step '{}' attempt {}/{}",
ctx.step.name,
ctx.attempt,
ctx.config.retries.limit
);
if !email.contains('@') {
return Err(NonRetryableError::new("invalid email address").into());
}
Ok(serde_json::json!({ "valid": true }))
}
},
)
.await?;

let name_for_step1 = params.name.clone();
let step1_result = step
.do_("initial-processing", move |_ctx| {
let name = name_for_step1.clone();
async move {
console_log!("Processing for user: {}", name);
Ok(serde_json::json!({
"processed": true,
"user": name
}))
}
})
.await?;

console_log!("Step 1 completed: {:?}", step1_result);

console_log!("Step 2: Sleeping for 10 seconds...");
step.sleep("wait-for-processing", "10 seconds").await?;

let email_for_step3 = params.email.clone();
let notification_result = step
.do_with_config(
"send-notification",
StepConfig {
retries: Some(RetryConfig {
limit: 3,
delay: "5 seconds".into(),
backoff: Some(Backoff::Exponential),
}),
timeout: Some("1 minute".into()),
sensitive: None,
},
move |_ctx| {
let email = email_for_step3.clone();
async move {
console_log!("Sending notification to: {}", email);
if js_sys::Math::random() < 0.5 {
return Err("notification service temporarily unavailable".into());
}
Ok(serde_json::json!({
"notification_sent": true,
"email": email
}))
}
},
)
.await?;

console_log!("Step 3 completed: {:?}", notification_result);

// Step 4: saga-style rollback. If a later step fails, the runtime
// invokes the rollback handler (in reverse order of completion) to undo
// this step's side effects. The handler receives the step's output, the
// original step context, and the error that triggered the rollback.
let reservation = step
.do_with_rollback(
"reserve-inventory",
|_ctx| async move {
console_log!("Reserving inventory");
Ok(serde_json::json!({ "reservation_id": "resv_123" }))
},
Rollback::new(
|rollback: WorkflowRollbackContext<serde_json::Value>| async move {
if let Some(output) = &rollback.output {
console_log!(
"Rolling back '{}' (caused by: {}) — releasing {}",
rollback.ctx.step.name,
rollback.error.message,
output
);
}
Ok(())
},
),
)
.await?;

console_log!("Step 4 completed: {:?}", reservation);

Ok(MyOutput {
message: format!("Workflow completed for {}", params.name),
steps_completed: 4,
})
}
}

#[event(fetch)]
async fn fetch(mut req: Request, env: Env, _ctx: Context) -> Result<Response> {
let url = req.url()?;
let path = url.path();
let workflow = env.workflow("MY_WORKFLOW")?;

match (req.method(), path) {
(Method::Post, "/workflow") => {
let params: MyParams = req.json().await?;

let instance = workflow
.create(CreateOptions {
params: Some(params),
..Default::default()
})
.await?;

Response::from_json(&serde_json::json!({
"id": instance.id(),
"message": "Workflow created"
}))
}

(Method::Get, path) if path.starts_with("/workflow/") => {
let id = path.trim_start_matches("/workflow/");
let instance = workflow.get(id).await?;
let status = instance.status().await?;

Response::from_json(&serde_json::json!({
"id": instance.id(),
"status": format!("{:?}", status.status),
"error": status.error,
"output": status.output
}))
}

(Method::Post, path) if path.starts_with("/workflow/") && path.ends_with("/pause") => {
let id = path
.trim_start_matches("/workflow/")
.trim_end_matches("/pause");
let instance = workflow.get(id).await?;
instance.pause().await?;

Response::from_json(&serde_json::json!({
"id": instance.id(),
"message": "Workflow paused"
}))
}

(Method::Post, path) if path.starts_with("/workflow/") && path.ends_with("/resume") => {
let id = path
.trim_start_matches("/workflow/")
.trim_end_matches("/resume");
let instance = workflow.get(id).await?;
instance.resume().await?;

Response::from_json(&serde_json::json!({
"id": instance.id(),
"message": "Workflow resumed"
}))
}

_ => Response::error("Not Found", 404),
}
}
16 changes: 16 additions & 0 deletions examples/workflow/wrangler.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
name = "workflow-example"
main = "build/worker/shim.mjs"
compatibility_date = "2026-04-21"

[build]
# For development: use local worker-build binary
# For production: command = "cargo install -q worker-build && worker-build --release"
command = "RUSTFLAGS='--cfg=web_sys_unstable_apis' ../../target/release/worker-build --release"

[[workflows]]
name = "my-workflow"
binding = "MY_WORKFLOW"
class_name = "MyWorkflow"
# Optionally trigger this workflow on a cron schedule. When triggered this way,
# `WorkflowEvent::schedule` is populated with the matching cron expression.
# schedules = ["0 * * * *", "*/15 * * * *"]
Loading