mirror of
https://github.com/ksyasuda/dotfiles.git
synced 2026-03-21 06:11:27 -07:00
update skills
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
# Cloudflare Workflows
|
||||
|
||||
Durable multi-step applications with automatic retries, state persistence, and long-running execution.
|
||||
|
||||
## What It Does
|
||||
|
||||
- Chain steps with automatic retry logic
|
||||
- Persist state between steps (minutes → weeks)
|
||||
- Handle failures without losing progress
|
||||
- Wait for external events/approvals
|
||||
- Sleep without consuming resources
|
||||
|
||||
**Available:** Free & Paid Workers plans
|
||||
|
||||
## Core Concepts
|
||||
|
||||
**Workflow**: Class extending `WorkflowEntrypoint` with `run` method
|
||||
**Instance**: Single execution with unique ID & independent state
|
||||
**Steps**: Independently retriable units via `step.do()` - API calls, DB queries, AI invocations
|
||||
**State**: Persisted from step returns; step name = cache key
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { WorkflowEntrypoint, WorkflowStep, WorkflowEvent } from 'cloudflare:workers';
|
||||
|
||||
type Env = { MY_WORKFLOW: Workflow; DB: D1Database };
|
||||
type Params = { userId: string };
|
||||
|
||||
export class MyWorkflow extends WorkflowEntrypoint<Env, Params> {
|
||||
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
|
||||
const user = await step.do('fetch user', async () => {
|
||||
return await this.env.DB.prepare('SELECT * FROM users WHERE id = ?')
|
||||
.bind(event.params.userId).first();
|
||||
});
|
||||
|
||||
await step.sleep('wait 7 days', '7 days');
|
||||
|
||||
await step.do('send reminder', async () => {
|
||||
await sendEmail(user.email, 'Reminder!');
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Durability**: Failed steps don't re-run successful ones
|
||||
- **Retries**: Configurable backoff (constant/linear/exponential)
|
||||
- **Events**: `waitForEvent()` for webhooks/approvals (timeout: 1h → 365d)
|
||||
- **Sleep**: `sleep()` / `sleepUntil()` for scheduling (max 365d)
|
||||
- **Parallel**: `Promise.all()` for concurrent steps
|
||||
- **Idempotency**: Check-then-execute patterns
|
||||
|
||||
## Reading Order
|
||||
|
||||
**Getting Started:** configuration.md → api.md → patterns.md
|
||||
**Troubleshooting:** gotchas.md
|
||||
|
||||
## In This Reference
|
||||
- [configuration.md](./configuration.md) - wrangler.jsonc setup, step config, bindings
|
||||
- [api.md](./api.md) - Step APIs, instance management, sleep/parameters
|
||||
- [patterns.md](./patterns.md) - Common workflows, testing, orchestration
|
||||
- [gotchas.md](./gotchas.md) - Timeouts, limits, debugging strategies
|
||||
|
||||
## See Also
|
||||
- [durable-objects](../durable-objects/) - Alternative stateful approach
|
||||
- [queues](../queues/) - Message-driven workflows
|
||||
- [workers](../workers/) - Entry point for workflow instances
|
||||
185
.agents/skills/cloudflare-deploy/references/workflows/api.md
Normal file
185
.agents/skills/cloudflare-deploy/references/workflows/api.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Workflow APIs
|
||||
|
||||
## Step APIs
|
||||
|
||||
```typescript
|
||||
// step.do()
|
||||
const result = await step.do('step name', async () => { /* logic */ });
|
||||
const result = await step.do('step name', { retries, timeout }, async () => {});
|
||||
|
||||
// step.sleep()
|
||||
await step.sleep('description', '1 hour');
|
||||
await step.sleep('description', 5000); // ms
|
||||
|
||||
// step.sleepUntil()
|
||||
await step.sleepUntil('description', Date.parse('2024-12-31'));
|
||||
|
||||
// step.waitForEvent()
|
||||
const data = await step.waitForEvent<PayloadType>('wait', {event: 'webhook-type', timeout: '24h'}); // Default 24h, max 365d
|
||||
try { const event = await step.waitForEvent('wait', { event: 'approval', timeout: '1h' }); } catch (e) { /* Timeout */ }
|
||||
```
|
||||
|
||||
## Instance Management
|
||||
|
||||
```typescript
|
||||
// Create single
|
||||
const instance = await env.MY_WORKFLOW.create({id: crypto.randomUUID(), params: { userId: 'user123' }}); // id optional, auto-generated if omitted
|
||||
|
||||
// Create with custom retention (default: 3 days free, 30 days paid)
|
||||
const instance = await env.MY_WORKFLOW.create({
|
||||
id: crypto.randomUUID(),
|
||||
params: { userId: 'user123' },
|
||||
retention: '30 days' // Override default retention period
|
||||
});
|
||||
|
||||
// Batch (max 100, idempotent: skips existing IDs)
|
||||
const instances = await env.MY_WORKFLOW.createBatch([{id: 'user1', params: {name: 'John'}}, {id: 'user2', params: {name: 'Jane'}}]);
|
||||
|
||||
// Get & Status
|
||||
const instance = await env.MY_WORKFLOW.get('instance-id');
|
||||
const status = await instance.status(); // {status: 'queued' | 'running' | 'paused' | 'errored' | 'terminated' | 'complete' | 'waiting' | 'waitingForPause' | 'unknown', error?, output?}
|
||||
|
||||
// Control
|
||||
await instance.pause(); await instance.resume(); await instance.terminate(); await instance.restart();
|
||||
|
||||
// Send Events
|
||||
await instance.sendEvent({type: 'approval', payload: { approved: true }}); // Must match waitForEvent type
|
||||
```
|
||||
|
||||
## Triggering Workflows
|
||||
|
||||
```typescript
|
||||
// From Worker
|
||||
export default { async fetch(req, env) { const instance = await env.MY_WORKFLOW.create({id: crypto.randomUUID(), params: { userId: 'user123' }}); return Response.json({ id: instance.id }); }};
|
||||
|
||||
// From Queue
|
||||
export default { async queue(batch, env) { for (const msg of batch.messages) { await env.MY_WORKFLOW.create({id: `job-${msg.id}`, params: msg.body}); } }};
|
||||
|
||||
// From Cron
|
||||
export default { async scheduled(event, env) { await env.CLEANUP_WORKFLOW.create({id: `cleanup-${Date.now()}`, params: { timestamp: event.scheduledTime }}); }};
|
||||
|
||||
// From Another Workflow (non-blocking)
|
||||
export class ParentWorkflow extends WorkflowEntrypoint<Env, Params> {
|
||||
async run(event, step) {
|
||||
const child = await step.do('start child', async () => await this.env.CHILD_WORKFLOW.create({id: `child-${event.instanceId}`, params: {}}));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
import { NonRetryableError } from 'cloudflare:workers';
|
||||
|
||||
// NonRetryableError
|
||||
await step.do('validate', async () => {
|
||||
if (!event.params.paymentMethod) throw new NonRetryableError('Payment method required');
|
||||
const res = await fetch('https://api.example.com/charge', { method: 'POST' });
|
||||
if (res.status === 401) throw new NonRetryableError('Invalid credentials'); // Don't retry
|
||||
if (!res.ok) throw new Error('Retryable failure'); // Will retry
|
||||
return res.json();
|
||||
});
|
||||
|
||||
// Catching Errors
|
||||
try { await step.do('risky op', async () => { throw new NonRetryableError('Failed'); }); } catch (e) { await step.do('cleanup', async () => {}); }
|
||||
|
||||
// Idempotency
|
||||
await step.do('charge', async () => {
|
||||
const sub = await fetch(`https://api/subscriptions/${id}`).then(r => r.json());
|
||||
if (sub.charged) return sub; // Already done
|
||||
return await fetch(`https://api/subscriptions/${id}`, {method: 'POST', body: JSON.stringify({ amount: 10.0 })}).then(r => r.json());
|
||||
});
|
||||
```
|
||||
|
||||
## Type Constraints
|
||||
|
||||
Params and step returns must be `Rpc.Serializable<T>`:
|
||||
|
||||
```typescript
|
||||
// ✅ Valid types
|
||||
type ValidParams = {
|
||||
userId: string;
|
||||
count: number;
|
||||
tags: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// ❌ Invalid types
|
||||
type InvalidParams = {
|
||||
callback: () => void; // Functions not serializable
|
||||
symbol: symbol; // Symbols not serializable
|
||||
circular: any; // Circular references not allowed
|
||||
};
|
||||
|
||||
// Step returns follow same rules
|
||||
const result = await step.do('fetch', async () => {
|
||||
return { userId: '123', data: [1, 2, 3] }; // ✅ Plain object
|
||||
});
|
||||
```
|
||||
|
||||
## Sleep & Scheduling
|
||||
|
||||
```typescript
|
||||
// Relative
|
||||
await step.sleep('wait 1 hour', '1 hour');
|
||||
await step.sleep('wait 30 days', '30 days');
|
||||
await step.sleep('wait 5s', 5000); // ms
|
||||
|
||||
// Absolute
|
||||
await step.sleepUntil('launch date', Date.parse('24 Oct 2024 13:00:00 UTC'));
|
||||
await step.sleepUntil('deadline', new Date('2024-12-31T23:59:59Z'));
|
||||
```
|
||||
|
||||
Units: second, minute, hour, day, week, month, year. Max: 365 days.
|
||||
Sleeping instances don't count toward concurrency.
|
||||
|
||||
## Parameters
|
||||
|
||||
**Pass from Worker:**
|
||||
```typescript
|
||||
const instance = await env.MY_WORKFLOW.create({
|
||||
id: crypto.randomUUID(),
|
||||
params: { userId: 'user123', email: 'user@example.com' }
|
||||
});
|
||||
```
|
||||
|
||||
**Access in Workflow:**
|
||||
```typescript
|
||||
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
|
||||
const userId = event.params.userId;
|
||||
const instanceId = event.instanceId;
|
||||
const createdAt = event.timestamp;
|
||||
}
|
||||
```
|
||||
|
||||
**CLI Trigger:**
|
||||
```bash
|
||||
npx wrangler workflows trigger my-workflow '{"userId":"user123"}'
|
||||
```
|
||||
|
||||
## Wrangler CLI
|
||||
|
||||
```bash
|
||||
npm create cloudflare@latest my-workflow -- --template "cloudflare/workflows-starter"
|
||||
npx wrangler deploy
|
||||
npx wrangler workflows list
|
||||
npx wrangler workflows trigger my-workflow '{"userId":"user123"}'
|
||||
npx wrangler workflows instances list my-workflow
|
||||
npx wrangler workflows instances describe my-workflow instance-id
|
||||
npx wrangler workflows instances pause/resume/terminate my-workflow instance-id
|
||||
```
|
||||
|
||||
## REST API
|
||||
|
||||
```bash
|
||||
# Create
|
||||
curl -X POST "https://api.cloudflare.com/client/v4/accounts/{account_id}/workflows/{workflow_name}/instances" -H "Authorization: Bearer {token}" -d '{"id":"custom-id","params":{"userId":"user123"}}'
|
||||
|
||||
# Status
|
||||
curl "https://api.cloudflare.com/client/v4/accounts/{account_id}/workflows/{workflow_name}/instances/{instance_id}/status" -H "Authorization: Bearer {token}"
|
||||
|
||||
# Send Event
|
||||
curl -X POST "https://api.cloudflare.com/client/v4/accounts/{account_id}/workflows/{workflow_name}/instances/{instance_id}/events" -H "Authorization: Bearer {token}" -d '{"type":"approval","payload":{"approved":true}}'
|
||||
```
|
||||
|
||||
See: [configuration.md](./configuration.md), [patterns.md](./patterns.md)
|
||||
@@ -0,0 +1,151 @@
|
||||
# Workflow Configuration
|
||||
|
||||
## wrangler.jsonc Setup
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"name": "my-worker",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2025-01-01", // Use current date for new projects
|
||||
"observability": {
|
||||
"enabled": true // Enables Workflows dashboard + structured logs
|
||||
},
|
||||
"workflows": [
|
||||
{
|
||||
"name": "my-workflow", // Workflow name
|
||||
"binding": "MY_WORKFLOW", // Env binding
|
||||
"class_name": "MyWorkflow" // TS class name
|
||||
// "script_name": "other-worker" // For cross-script calls
|
||||
}
|
||||
],
|
||||
"limits": {
|
||||
"cpu_ms": 300000 // 5 min max (default 30s)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step Configuration
|
||||
|
||||
```typescript
|
||||
// Basic step
|
||||
const data = await step.do('step name', async () => ({ result: 'value' }));
|
||||
|
||||
// With retry config
|
||||
await step.do('api call', {
|
||||
retries: {
|
||||
limit: 10, // Default: 5, or Infinity
|
||||
delay: '10 seconds', // Default: 10000ms
|
||||
backoff: 'exponential' // constant | linear | exponential
|
||||
},
|
||||
timeout: '30 minutes' // Per-attempt timeout (default: 10min)
|
||||
}, async () => {
|
||||
const res = await fetch('https://api.example.com/data');
|
||||
if (!res.ok) throw new Error('Failed');
|
||||
return res.json();
|
||||
});
|
||||
```
|
||||
|
||||
### Parallel Steps
|
||||
```typescript
|
||||
const [user, settings] = await Promise.all([
|
||||
step.do('fetch user', async () => this.env.KV.get(`user:${id}`)),
|
||||
step.do('fetch settings', async () => this.env.KV.get(`settings:${id}`))
|
||||
]);
|
||||
```
|
||||
|
||||
### Conditional Steps
|
||||
```typescript
|
||||
const config = await step.do('fetch config', async () =>
|
||||
this.env.KV.get('flags', { type: 'json' })
|
||||
);
|
||||
|
||||
// ✅ Deterministic (based on step output)
|
||||
if (config.enableEmail) {
|
||||
await step.do('send email', async () => sendEmail());
|
||||
}
|
||||
|
||||
// ❌ Non-deterministic (Date.now outside step)
|
||||
if (Date.now() > deadline) { /* BAD */ }
|
||||
```
|
||||
|
||||
### Dynamic Steps (Loops)
|
||||
```typescript
|
||||
const files = await step.do('list files', async () =>
|
||||
this.env.BUCKET.list()
|
||||
);
|
||||
|
||||
for (const file of files.objects) {
|
||||
await step.do(`process ${file.key}`, async () => {
|
||||
const obj = await this.env.BUCKET.get(file.key);
|
||||
return processData(await obj.arrayBuffer());
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Multiple Workflows
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"workflows": [
|
||||
{"name": "user-onboarding", "binding": "USER_ONBOARDING", "class_name": "UserOnboarding"},
|
||||
{"name": "data-processing", "binding": "DATA_PROCESSING", "class_name": "DataProcessing"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Each class extends `WorkflowEntrypoint` with its own `Params` type.
|
||||
|
||||
## Cross-Script Bindings
|
||||
|
||||
Worker A defines workflow. Worker B calls it by adding `script_name`:
|
||||
|
||||
```jsonc
|
||||
// Worker B (caller)
|
||||
{
|
||||
"workflows": [{
|
||||
"name": "billing-workflow",
|
||||
"binding": "BILLING",
|
||||
"script_name": "billing-worker" // Points to Worker A
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
## Bindings
|
||||
|
||||
Workflows access Cloudflare bindings via `this.env`:
|
||||
|
||||
```typescript
|
||||
type Env = {
|
||||
MY_WORKFLOW: Workflow;
|
||||
KV: KVNamespace;
|
||||
DB: D1Database;
|
||||
BUCKET: R2Bucket;
|
||||
AI: Ai;
|
||||
VECTORIZE: VectorizeIndex;
|
||||
};
|
||||
|
||||
await step.do('use bindings', async () => {
|
||||
const kv = await this.env.KV.get('key');
|
||||
const db = await this.env.DB.prepare('SELECT * FROM users').first();
|
||||
const file = await this.env.BUCKET.get('file.txt');
|
||||
const ai = await this.env.AI.run('@cf/meta/llama-2-7b-chat-int8', { prompt: 'Hi' });
|
||||
});
|
||||
```
|
||||
|
||||
## Pages Functions Binding
|
||||
|
||||
Pages Functions can trigger Workflows via service bindings:
|
||||
|
||||
```typescript
|
||||
// functions/_middleware.ts
|
||||
export const onRequest: PagesFunction<Env> = async ({ env, request }) => {
|
||||
const instance = await env.MY_WORKFLOW.create({
|
||||
params: { url: request.url }
|
||||
});
|
||||
return new Response(`Started ${instance.id}`);
|
||||
};
|
||||
```
|
||||
|
||||
Configure in wrangler.jsonc under `service_bindings`.
|
||||
|
||||
See: [api.md](./api.md), [patterns.md](./patterns.md)
|
||||
@@ -0,0 +1,97 @@
|
||||
# Gotchas & Debugging
|
||||
|
||||
## Common Errors
|
||||
|
||||
### "Step Timeout"
|
||||
|
||||
**Cause:** Step execution exceeding 10 minute default timeout or configured timeout
|
||||
**Solution:** Set custom timeout with `step.do('long operation', {timeout: '30 minutes'}, async () => {...})` or increase CPU limit in wrangler.jsonc (max 5min CPU time)
|
||||
|
||||
### "waitForEvent Timeout"
|
||||
|
||||
**Cause:** Event not received within timeout period (default 24h, max 365d)
|
||||
**Solution:** Wrap in try-catch to handle timeout gracefully and proceed with default behavior
|
||||
|
||||
### "Non-Deterministic Step Names"
|
||||
|
||||
**Cause:** Using dynamic values like `Date.now()` in step names causes replay issues
|
||||
**Solution:** Use deterministic values like `event.instanceId` for step names
|
||||
|
||||
### "State Lost in Variables"
|
||||
|
||||
**Cause:** Using module-level or local variables to store state which is lost on hibernation
|
||||
**Solution:** Return values from `step.do()` which are automatically persisted: `const total = await step.do('step 1', async () => 10)`
|
||||
|
||||
### "Non-Deterministic Conditionals"
|
||||
|
||||
**Cause:** Using non-deterministic logic (like `Date.now()`) outside steps in conditionals
|
||||
**Solution:** Move non-deterministic operations inside steps: `const isLate = await step.do('check', async () => Date.now() > deadline)`
|
||||
|
||||
### "Large Step Returns Exceeding Limit"
|
||||
|
||||
**Cause:** Returning data >1 MiB from step
|
||||
**Solution:** Store large data in R2 and return only reference: `{ key: 'r2-object-key' }`
|
||||
|
||||
### "Step Exceeded CPU Limit But Ran for < 30s"
|
||||
|
||||
**Cause:** Confusion between CPU time (active compute) and wall-clock time (includes I/O waits)
|
||||
**Solution:** Network requests, database queries, and sleeps don't count toward CPU. 30s limit = 30s of active processing
|
||||
|
||||
### "Idempotency Violation"
|
||||
|
||||
**Cause:** Step operations not idempotent, causing duplicate charges or actions on retry
|
||||
**Solution:** Check if operation already completed before executing (e.g., check if customer already charged)
|
||||
|
||||
### "Instance ID Collision"
|
||||
|
||||
**Cause:** Reusing instance IDs causing conflicts
|
||||
**Solution:** Use unique IDs with timestamp: `await env.MY_WORKFLOW.create({ id: \`${userId}-${Date.now()}\`, params: {} })`
|
||||
|
||||
### "Instance Data Disappeared After Completion"
|
||||
|
||||
**Cause:** Completed/errored instances are automatically deleted after retention period (3 days free / 30 days paid)
|
||||
**Solution:** Export critical data to KV/R2/D1 before workflow completes
|
||||
|
||||
### "Missing await on step.do"
|
||||
|
||||
**Cause:** Forgetting to await step.do() causing fire-and-forget behavior
|
||||
**Solution:** Always await step operations: `await step.do('task', ...)`
|
||||
|
||||
## Limits
|
||||
|
||||
| Limit | Free | Paid | Notes |
|
||||
|-------|------|------|-------|
|
||||
| CPU per step | 10ms | 30s (default), 5min (max) | Set via `limits.cpu_ms` in wrangler.jsonc |
|
||||
| Step state | 1 MiB | 1 MiB | Per step return value |
|
||||
| Instance state | 100 MB | 1 GB | Total state per workflow instance |
|
||||
| Steps per workflow | 1,024 | 1,024 | `step.sleep()` doesn't count |
|
||||
| Executions per day | 100k | Unlimited | Daily execution limit |
|
||||
| Concurrent instances | 25 | 10k | Maximum concurrent workflows; waiting state excluded |
|
||||
| Queued instances | 100k | 1M | Maximum queued workflow instances |
|
||||
| Subrequests per step | 50 | 1,000 | Maximum outbound requests per step |
|
||||
| State retention | 3 days | 30 days | How long completed instances kept |
|
||||
| Step timeout default | 10 min | 10 min | Per attempt |
|
||||
| waitForEvent timeout default | 24h | 24h | Maximum 365 days |
|
||||
| waitForEvent timeout max | 365 days | 365 days | Maximum wait time |
|
||||
|
||||
**Note:** Instances in `waiting` state (from `step.sleep` or `step.waitForEvent`) don't count toward concurrent instance limit, allowing millions of sleeping workflows.
|
||||
|
||||
## Pricing
|
||||
|
||||
| Metric | Free | Paid | Notes |
|
||||
|--------|------|------|-------|
|
||||
| Requests | 100k/day | 10M/mo + $0.30/M | Workflow invocations |
|
||||
| CPU time | 10ms/invoke | 30M CPU-ms/mo + $0.02/M CPU-ms | Actual CPU usage |
|
||||
| Storage | 1 GB | 1 GB/mo + $0.20/GB-mo | All instances (running/errored/sleeping/completed) |
|
||||
|
||||
## References
|
||||
|
||||
- [Official Docs](https://developers.cloudflare.com/workflows/)
|
||||
- [Get Started Guide](https://developers.cloudflare.com/workflows/get-started/guide/)
|
||||
- [Workers API](https://developers.cloudflare.com/workflows/build/workers-api/)
|
||||
- [REST API](https://developers.cloudflare.com/api/resources/workflows/)
|
||||
- [Examples](https://developers.cloudflare.com/workflows/examples/)
|
||||
- [Limits](https://developers.cloudflare.com/workflows/reference/limits/)
|
||||
- [Pricing](https://developers.cloudflare.com/workflows/reference/pricing/)
|
||||
|
||||
See: [README.md](./README.md), [configuration.md](./configuration.md), [api.md](./api.md), [patterns.md](./patterns.md)
|
||||
@@ -0,0 +1,175 @@
|
||||
# Workflow Patterns
|
||||
|
||||
## Image Processing Pipeline
|
||||
|
||||
```typescript
|
||||
export class ImageProcessingWorkflow extends WorkflowEntrypoint<Env, Params> {
|
||||
async run(event, step) {
|
||||
const imageData = await step.do('fetch', async () => (await this.env.BUCKET.get(event.params.imageKey)).arrayBuffer());
|
||||
const description = await step.do('generate description', async () =>
|
||||
await this.env.AI.run('@cf/llava-hf/llava-1.5-7b-hf', {image: Array.from(new Uint8Array(imageData)), prompt: 'Describe this image', max_tokens: 50})
|
||||
);
|
||||
await step.waitForEvent('await approval', { event: 'approved', timeout: '24h' });
|
||||
await step.do('publish', async () => await this.env.BUCKET.put(`public/${event.params.imageKey}`, imageData));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## User Lifecycle
|
||||
|
||||
```typescript
|
||||
export class UserLifecycleWorkflow extends WorkflowEntrypoint<Env, Params> {
|
||||
async run(event, step) {
|
||||
await step.do('welcome email', async () => await sendEmail(event.params.email, 'Welcome!'));
|
||||
await step.sleep('trial period', '7 days');
|
||||
const hasConverted = await step.do('check conversion', async () => {
|
||||
const user = await this.env.DB.prepare('SELECT subscription_status FROM users WHERE id = ?').bind(event.params.userId).first();
|
||||
return user.subscription_status === 'active';
|
||||
});
|
||||
if (!hasConverted) await step.do('trial expiration email', async () => await sendEmail(event.params.email, 'Trial ending'));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Data Pipeline
|
||||
|
||||
```typescript
|
||||
export class DataPipelineWorkflow extends WorkflowEntrypoint<Env, Params> {
|
||||
async run(event, step) {
|
||||
const rawData = await step.do('extract', {retries: { limit: 10, delay: '30s', backoff: 'exponential' }}, async () => {
|
||||
const res = await fetch(event.params.sourceUrl);
|
||||
if (!res.ok) throw new Error('Fetch failed');
|
||||
return res.json();
|
||||
});
|
||||
const transformed = await step.do('transform', async () =>
|
||||
rawData.map(item => ({ id: item.id, normalized: normalizeData(item) }))
|
||||
);
|
||||
const dataRef = await step.do('store', async () => {
|
||||
const key = `processed/${Date.now()}.json`;
|
||||
await this.env.BUCKET.put(key, JSON.stringify(transformed));
|
||||
return { key };
|
||||
});
|
||||
await step.do('load', async () => {
|
||||
const data = await (await this.env.BUCKET.get(dataRef.key)).json();
|
||||
for (let i = 0; i < data.length; i += 100) {
|
||||
await this.env.DB.batch(data.slice(i, i + 100).map(item =>
|
||||
this.env.DB.prepare('INSERT INTO records VALUES (?, ?)').bind(item.id, item.normalized)
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Human-in-the-Loop Approval
|
||||
|
||||
```typescript
|
||||
export class ApprovalWorkflow extends WorkflowEntrypoint<Env, Params> {
|
||||
async run(event, step) {
|
||||
await step.do('create approval', async () => await this.env.DB.prepare('INSERT INTO approvals (id, user_id, status) VALUES (?, ?, ?)').bind(event.instanceId, event.params.userId, 'pending').run());
|
||||
try {
|
||||
const approval = await step.waitForEvent<{ approved: boolean }>('wait for approval', { event: 'approval-response', timeout: '48h' });
|
||||
if (approval.approved) { await step.do('process approval', async () => {}); }
|
||||
else { await step.do('handle rejection', async () => {}); }
|
||||
} catch (e) {
|
||||
await step.do('auto reject', async () => await this.env.DB.prepare('UPDATE approvals SET status = ? WHERE id = ?').bind('auto-rejected', event.instanceId).run());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Workflows
|
||||
|
||||
### Setup
|
||||
|
||||
```typescript
|
||||
// vitest.config.ts
|
||||
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
|
||||
|
||||
export default defineWorkersConfig({
|
||||
test: {
|
||||
poolOptions: {
|
||||
workers: {
|
||||
wrangler: { configPath: './wrangler.jsonc' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Introspection API
|
||||
|
||||
```typescript
|
||||
import { introspectWorkflowInstance } from 'cloudflare:test';
|
||||
|
||||
const instance = await env.MY_WORKFLOW.create({ params: { userId: '123' } });
|
||||
const introspector = await introspectWorkflowInstance(env.MY_WORKFLOW, instance.id);
|
||||
|
||||
// Wait for step completion
|
||||
const result = await introspector.waitForStepResult({ name: 'fetch user', index: 0 });
|
||||
|
||||
// Mock step behavior
|
||||
await introspector.modify(async (m) => {
|
||||
await m.mockStepResult({ name: 'api call' }, { mocked: true });
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ✅ DO
|
||||
|
||||
1. **Granular steps**: One API call per step (unless proving idempotency)
|
||||
2. **Idempotency**: Check-then-execute; use idempotency keys
|
||||
3. **Deterministic names**: Use static or step-output-based names
|
||||
4. **Return state**: Persist via step returns, not variables
|
||||
5. **Always await**: `await step.do()`, avoid dangling promises
|
||||
6. **Deterministic conditionals**: Base on `event.payload` or step outputs
|
||||
7. **Store large data externally**: R2/KV for >1 MiB, return refs
|
||||
8. **Batch creation**: `createBatch()` for multiple instances
|
||||
|
||||
### ❌ DON'T
|
||||
|
||||
1. **One giant step**: Breaks durability & retry control
|
||||
2. **State outside steps**: Lost on hibernation
|
||||
3. **Mutate events**: Events immutable, return new state
|
||||
4. **Non-deterministic logic outside steps**: `Math.random()`, `Date.now()` must be in steps
|
||||
5. **Side effects outside steps**: May duplicate on restart
|
||||
6. **Non-deterministic step names**: Prevents caching
|
||||
7. **Ignore timeouts**: `waitForEvent` throws, use try-catch
|
||||
8. **Reuse instance IDs**: Must be unique within retention
|
||||
|
||||
## Orchestration Patterns
|
||||
|
||||
### Fan-Out (Parallel Processing)
|
||||
```typescript
|
||||
const files = await step.do('list', async () => this.env.BUCKET.list());
|
||||
await Promise.all(files.objects.map((file, i) => step.do(`process ${i}`, async () => processFile(await (await this.env.BUCKET.get(file.key)).arrayBuffer()))));
|
||||
```
|
||||
|
||||
### Parent-Child Workflows
|
||||
```typescript
|
||||
const child = await step.do('start child', async () => await this.env.CHILD_WORKFLOW.create({id: `child-${event.instanceId}`, params: { data: result.data }}));
|
||||
await step.do('other work', async () => console.log(`Child started: ${child.id}`));
|
||||
```
|
||||
|
||||
### Race Pattern
|
||||
```typescript
|
||||
const winner = await Promise.race([
|
||||
step.do('option A', async () => slowOperation()),
|
||||
step.do('option B', async () => fastOperation())
|
||||
]);
|
||||
```
|
||||
|
||||
### Scheduled Workflow Chain
|
||||
```typescript
|
||||
export default { async scheduled(event, env) { await env.DAILY_WORKFLOW.create({id: `daily-${event.scheduledTime}`, params: { timestamp: event.scheduledTime }}); }};
|
||||
export class DailyWorkflow extends WorkflowEntrypoint<Env, Params> {
|
||||
async run(event, step) {
|
||||
await step.do('daily task', async () => {});
|
||||
await step.sleep('wait 7 days', '7 days');
|
||||
await step.do('weekly followup', async () => {});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See: [configuration.md](./configuration.md), [api.md](./api.md), [gotchas.md](./gotchas.md)
|
||||
Reference in New Issue
Block a user