Initial commit
This commit is contained in:
413
references/common-issues.md
Normal file
413
references/common-issues.md
Normal file
@@ -0,0 +1,413 @@
|
||||
# Cloudflare Workflows - Common Issues
|
||||
|
||||
**Last Updated**: 2025-10-22
|
||||
|
||||
This document details all known issues with Cloudflare Workflows and their solutions.
|
||||
|
||||
---
|
||||
|
||||
## Issue #1: I/O Context Error
|
||||
|
||||
**Error Message:**
|
||||
```
|
||||
Cannot perform I/O on behalf of a different request
|
||||
```
|
||||
|
||||
**Description:**
|
||||
When trying to use I/O objects (like fetch responses, file handles, etc.) created in one request context from a different request's handler, Cloudflare Workers throws this error. This is a fundamental Workers platform limitation.
|
||||
|
||||
**Root Cause:**
|
||||
I/O objects are bound to the request context that created them. Workflows create a new execution context for each step, so I/O must happen within the step's callback.
|
||||
|
||||
**Prevention:**
|
||||
|
||||
❌ **Bad - I/O outside step:**
|
||||
```typescript
|
||||
// This will fail!
|
||||
const response = await fetch('https://api.example.com/data');
|
||||
const data = await response.json();
|
||||
|
||||
await step.do('use data', async () => {
|
||||
// Trying to use data from outside step's context
|
||||
return data; // ❌ Error!
|
||||
});
|
||||
```
|
||||
|
||||
✅ **Good - I/O inside step:**
|
||||
```typescript
|
||||
const data = await step.do('fetch data', async () => {
|
||||
const response = await fetch('https://api.example.com/data');
|
||||
return await response.json(); // ✅ Correct
|
||||
});
|
||||
```
|
||||
|
||||
**Workaround:**
|
||||
Always perform all I/O operations (fetch, KV reads, D1 queries, R2 operations) within `step.do()` callbacks.
|
||||
|
||||
**Source:** Cloudflare Workers platform limitation
|
||||
|
||||
---
|
||||
|
||||
## Issue #2: NonRetryableError Behaves Differently in Dev vs Production
|
||||
|
||||
**Error Message:**
|
||||
```
|
||||
(No specific error - workflow retries when it shouldn't)
|
||||
```
|
||||
|
||||
**Description:**
|
||||
When throwing a `NonRetryableError` with an empty message in development mode (`wrangler dev`), the workflow incorrectly retries the failed step. In production, it correctly exits without retrying.
|
||||
|
||||
**Root Cause:**
|
||||
Bug in the development environment handling of empty NonRetryableError messages.
|
||||
|
||||
**Prevention:**
|
||||
|
||||
❌ **Bad - Empty message:**
|
||||
```typescript
|
||||
import { NonRetryableError } from 'cloudflare:workflows';
|
||||
|
||||
// May retry in dev mode
|
||||
throw new NonRetryableError();
|
||||
```
|
||||
|
||||
✅ **Good - Always provide message:**
|
||||
```typescript
|
||||
import { NonRetryableError } from 'cloudflare:workflows';
|
||||
|
||||
// Works consistently in dev and production
|
||||
throw new NonRetryableError('User not found');
|
||||
throw new NonRetryableError('Invalid authentication credentials');
|
||||
throw new NonRetryableError('Amount exceeds limit');
|
||||
```
|
||||
|
||||
**Workaround:**
|
||||
Always provide a descriptive message when throwing NonRetryableError.
|
||||
|
||||
**Source:** [cloudflare/workers-sdk#10113](https://github.com/cloudflare/workers-sdk/issues/10113)
|
||||
**Status:** Reported July 2025, not yet fixed
|
||||
|
||||
---
|
||||
|
||||
## Issue #3: WorkflowEvent Export Not Found
|
||||
|
||||
**Error Message:**
|
||||
```
|
||||
The requested module 'cloudflare:workers' does not provide an export named 'WorkflowEvent'
|
||||
```
|
||||
|
||||
**Description:**
|
||||
TypeScript cannot find the `WorkflowEvent` export from the `cloudflare:workers` module. This usually happens with outdated type definitions.
|
||||
|
||||
**Root Cause:**
|
||||
- Outdated `@cloudflare/workers-types` package
|
||||
- Incorrect import statement
|
||||
- Missing types in tsconfig.json
|
||||
|
||||
**Prevention:**
|
||||
|
||||
✅ **Ensure latest types installed:**
|
||||
```bash
|
||||
npm install -D @cloudflare/workers-types@latest
|
||||
```
|
||||
|
||||
✅ **Correct import:**
|
||||
```typescript
|
||||
import { WorkflowEntrypoint, WorkflowStep, WorkflowEvent } from 'cloudflare:workers';
|
||||
import { NonRetryableError } from 'cloudflare:workflows';
|
||||
```
|
||||
|
||||
✅ **Correct tsconfig.json:**
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"types": ["@cloudflare/workers-types/2023-07-01"],
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Workaround:**
|
||||
1. Update workers types: `npm install -D @cloudflare/workers-types@latest`
|
||||
2. Run type generation: `npx wrangler types`
|
||||
3. Restart TypeScript server in your editor
|
||||
|
||||
**Source:** Community reports, package versioning issues
|
||||
**Latest Working Version:** @cloudflare/workers-types@4.20251014.0 (verified 2025-10-22)
|
||||
|
||||
---
|
||||
|
||||
## Issue #4: Serialization Error - Non-Serializable Return Values
|
||||
|
||||
**Error Message:**
|
||||
```
|
||||
Error: Could not serialize return value
|
||||
(or workflow hangs without clear error)
|
||||
```
|
||||
|
||||
**Description:**
|
||||
Attempting to return non-serializable values from `step.do()` or `run()` methods causes serialization failures. The workflow instance may error or hang.
|
||||
|
||||
**Root Cause:**
|
||||
Workflows persist state between steps by serializing return values. Only JSON-serializable types are supported.
|
||||
|
||||
**Prevention:**
|
||||
|
||||
❌ **Bad - Non-serializable types:**
|
||||
```typescript
|
||||
// ❌ Function
|
||||
await step.do('bad example', async () => {
|
||||
return {
|
||||
data: [1, 2, 3],
|
||||
transform: (x) => x * 2 // ❌ Function not serializable
|
||||
};
|
||||
});
|
||||
|
||||
// ❌ Circular reference
|
||||
await step.do('bad example 2', async () => {
|
||||
const obj: any = { name: 'test' };
|
||||
obj.self = obj; // ❌ Circular reference
|
||||
return obj;
|
||||
});
|
||||
|
||||
// ❌ Symbol
|
||||
await step.do('bad example 3', async () => {
|
||||
return {
|
||||
id: Symbol('unique'), // ❌ Symbol not serializable
|
||||
data: 'test'
|
||||
};
|
||||
});
|
||||
|
||||
// ❌ undefined (use null instead)
|
||||
await step.do('bad example 4', async () => {
|
||||
return {
|
||||
value: undefined // ❌ undefined not serializable
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
✅ **Good - Only serializable types:**
|
||||
```typescript
|
||||
await step.do('good example', async () => {
|
||||
return {
|
||||
// ✅ Primitives
|
||||
string: 'value',
|
||||
number: 123,
|
||||
boolean: true,
|
||||
nullValue: null,
|
||||
|
||||
// ✅ Arrays
|
||||
array: [1, 2, 3],
|
||||
|
||||
// ✅ Objects
|
||||
nested: {
|
||||
data: 'test',
|
||||
items: [{ id: 1 }, { id: 2 }]
|
||||
}
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
✅ **Convert class instances to plain objects:**
|
||||
```typescript
|
||||
class User {
|
||||
constructor(public id: string, public name: string) {}
|
||||
|
||||
toJSON() {
|
||||
return { id: this.id, name: this.name };
|
||||
}
|
||||
}
|
||||
|
||||
await step.do('serialize class', async () => {
|
||||
const user = new User('123', 'Alice');
|
||||
|
||||
// ✅ Convert to plain object
|
||||
return user.toJSON(); // { id: '123', name: 'Alice' }
|
||||
});
|
||||
```
|
||||
|
||||
**Workaround:**
|
||||
- Only return primitives, arrays, and plain objects
|
||||
- Convert class instances to plain objects before returning
|
||||
- Use `null` instead of `undefined`
|
||||
- Avoid circular references
|
||||
|
||||
**Source:** Cloudflare Workflows documentation
|
||||
**Reference:** [Workflows Workers API](https://developers.cloudflare.com/workflows/build/workers-api/)
|
||||
|
||||
---
|
||||
|
||||
## Issue #5: Testing Workflows in CI Environments
|
||||
|
||||
**Error Message:**
|
||||
```
|
||||
(Tests pass locally but fail in CI)
|
||||
```
|
||||
|
||||
**Description:**
|
||||
Tests that use `vitest-pool-workers` to test workflows work reliably in local development but fail inconsistently in CI environments (GitHub Actions, GitLab CI, etc.).
|
||||
|
||||
**Root Cause:**
|
||||
- Timing issues in CI environments
|
||||
- Resource constraints in CI runners
|
||||
- Race conditions in test setup/teardown
|
||||
|
||||
**Prevention:**
|
||||
|
||||
✅ **Increase timeouts in CI:**
|
||||
```typescript
|
||||
// vitest.config.ts
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
testTimeout: 30000, // Increase from default 5000ms
|
||||
poolOptions: {
|
||||
workers: {
|
||||
wrangler: { configPath: './wrangler.jsonc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
✅ **Add retry logic for flaky tests:**
|
||||
```typescript
|
||||
describe('Workflow tests', () => {
|
||||
it.retry(3)('should complete workflow', async () => {
|
||||
// Test code
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
✅ **Use proper test isolation:**
|
||||
```typescript
|
||||
import { beforeEach, afterEach } from 'vitest';
|
||||
|
||||
let instance: WorkflowInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
instance = await env.MY_WORKFLOW.create({
|
||||
params: { userId: '123' }
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (instance) {
|
||||
try {
|
||||
await instance.terminate();
|
||||
} catch (error) {
|
||||
// Instance may already be terminated
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Workaround:**
|
||||
1. Increase test timeouts for CI
|
||||
2. Add retry logic for flaky tests
|
||||
3. Use proper test isolation
|
||||
4. Consider mocking workflows in unit tests, testing real workflows in integration tests
|
||||
|
||||
**Source:** [cloudflare/workers-sdk#10600](https://github.com/cloudflare/workers-sdk/issues/10600)
|
||||
**Status:** Ongoing investigation
|
||||
|
||||
---
|
||||
|
||||
## Additional Troubleshooting Tips
|
||||
|
||||
### Workflow Instance Stuck in "Running" State
|
||||
|
||||
**Possible Causes:**
|
||||
1. Step is sleeping for long duration
|
||||
2. Step is waiting for event that never arrives
|
||||
3. Step is retrying with long backoff
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Check detailed instance status
|
||||
npx wrangler workflows instances describe my-workflow <instance-id>
|
||||
|
||||
# Look for:
|
||||
# - Sleep state (shows wake time)
|
||||
# - waitForEvent state (shows event type and timeout)
|
||||
# - Retry history (shows attempts and delays)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step Returns Undefined
|
||||
|
||||
**Cause:** Missing return statement in step callback
|
||||
|
||||
**Solution:**
|
||||
```typescript
|
||||
// ❌ Bad - no return
|
||||
const result = await step.do('get data', async () => {
|
||||
const data = await fetchData();
|
||||
// Missing return!
|
||||
});
|
||||
console.log(result); // undefined
|
||||
|
||||
// ✅ Good - explicit return
|
||||
const result = await step.do('get data', async () => {
|
||||
const data = await fetchData();
|
||||
return data; // ✅ Return the value
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Payload Too Large Error
|
||||
|
||||
**Error:**
|
||||
```
|
||||
Payload size exceeds limit
|
||||
```
|
||||
|
||||
**Cause:** Workflow parameters or step outputs exceed 128 KB
|
||||
|
||||
**Solution:**
|
||||
```typescript
|
||||
// ❌ Bad - large payload
|
||||
await env.MY_WORKFLOW.create({
|
||||
params: {
|
||||
largeData: hugeArray // >128 KB
|
||||
}
|
||||
});
|
||||
|
||||
// ✅ Good - store in R2/KV, pass reference
|
||||
const key = `workflow-data/${crypto.randomUUID()}`;
|
||||
await env.MY_BUCKET.put(key, JSON.stringify(hugeArray));
|
||||
|
||||
await env.MY_WORKFLOW.create({
|
||||
params: {
|
||||
dataKey: key // Just pass the key
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you encounter issues not listed here:
|
||||
|
||||
1. **Check Cloudflare Status**: https://www.cloudflarestatus.com/
|
||||
2. **Search GitHub Issues**: https://github.com/cloudflare/workers-sdk/issues
|
||||
3. **Cloudflare Discord**: https://discord.gg/cloudflaredev
|
||||
4. **Cloudflare Community**: https://community.cloudflare.com/
|
||||
5. **Official Docs**: https://developers.cloudflare.com/workflows/
|
||||
|
||||
When reporting issues, include:
|
||||
- Workflow code (sanitized)
|
||||
- Wrangler configuration
|
||||
- Error messages and stack traces
|
||||
- Workflow instance ID
|
||||
- Steps to reproduce
|
||||
- Expected vs actual behavior
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-10-22
|
||||
**Maintainer**: Jeremy Dawes | jeremy@jezweb.net
|
||||
585
references/workflow-patterns.md
Normal file
585
references/workflow-patterns.md
Normal file
@@ -0,0 +1,585 @@
|
||||
# Cloudflare Workflows - Production Patterns
|
||||
|
||||
**Last Updated**: 2025-10-22
|
||||
|
||||
This document provides battle-tested patterns for building production-ready Cloudflare Workflows.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Idempotency Patterns](#idempotency-patterns)
|
||||
2. [Error Handling Patterns](#error-handling-patterns)
|
||||
3. [Long-Running Process Patterns](#long-running-process-patterns)
|
||||
4. [Human-in-the-Loop Patterns](#human-in-the-loop-patterns)
|
||||
5. [Workflow Chaining Patterns](#workflow-chaining-patterns)
|
||||
6. [Testing Patterns](#testing-patterns)
|
||||
7. [Monitoring Patterns](#monitoring-patterns)
|
||||
|
||||
---
|
||||
|
||||
## Idempotency Patterns
|
||||
|
||||
### Pattern 1: Idempotency Keys
|
||||
|
||||
**Problem:** Workflow steps may execute multiple times due to retries.
|
||||
|
||||
**Solution:** Use idempotency keys to ensure operations execute only once.
|
||||
|
||||
```typescript
|
||||
export class PaymentWorkflow extends WorkflowEntrypoint<Env, PaymentParams> {
|
||||
async run(event: WorkflowEvent<PaymentParams>, step: WorkflowStep) {
|
||||
const { orderId, amount } = event.payload;
|
||||
|
||||
// Generate idempotency key from workflow instance ID + step name
|
||||
const idempotencyKey = `${event.instanceId}-charge-payment`;
|
||||
|
||||
const paymentResult = await step.do('charge payment', async () => {
|
||||
// Check if already processed
|
||||
const existing = await this.env.KV.get(`payment:${idempotencyKey}`);
|
||||
if (existing) {
|
||||
console.log('Payment already processed, returning cached result');
|
||||
return JSON.parse(existing);
|
||||
}
|
||||
|
||||
// Process payment
|
||||
const response = await fetch('https://payment-gateway.example.com/charge', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Idempotency-Key': idempotencyKey // Payment gateway checks this
|
||||
},
|
||||
body: JSON.stringify({ orderId, amount })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Cache result
|
||||
await this.env.KV.put(
|
||||
`payment:${idempotencyKey}`,
|
||||
JSON.stringify(result),
|
||||
{ expirationTtl: 86400 } // 24 hours
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
return { orderId, transactionId: paymentResult.transactionId };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Pattern 2: Database Upsert for Idempotency
|
||||
|
||||
```typescript
|
||||
await step.do('create order', async () => {
|
||||
// Use INSERT OR REPLACE to make idempotent
|
||||
await this.env.DB.prepare(`
|
||||
INSERT INTO orders (id, user_id, amount, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
amount = excluded.amount,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`).bind(
|
||||
orderId,
|
||||
userId,
|
||||
amount,
|
||||
'pending',
|
||||
new Date().toISOString()
|
||||
).run();
|
||||
|
||||
return { orderId };
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling Patterns
|
||||
|
||||
### Pattern 1: Categorize Errors for Retry Logic
|
||||
|
||||
```typescript
|
||||
async function shouldRetry(error: Error): Promise<boolean> {
|
||||
// Don't retry on client errors (4xx)
|
||||
if (error.message.includes('400') ||
|
||||
error.message.includes('401') ||
|
||||
error.message.includes('403') ||
|
||||
error.message.includes('404')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Retry on server errors (5xx) and network errors
|
||||
return true;
|
||||
}
|
||||
|
||||
await step.do('call API', async () => {
|
||||
try {
|
||||
const response = await fetch('https://api.example.com/data');
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`API error: ${response.status}`);
|
||||
|
||||
if (!await shouldRetry(error)) {
|
||||
throw new NonRetryableError(error.message);
|
||||
}
|
||||
|
||||
throw error; // Will retry
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
if (error instanceof NonRetryableError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Network error - retry
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Pattern 2: Circuit Breaker
|
||||
|
||||
```typescript
|
||||
export class CircuitBreaker {
|
||||
constructor(
|
||||
private kv: KVNamespace,
|
||||
private serviceName: string,
|
||||
private threshold: number = 5,
|
||||
private resetTime: number = 60000 // 1 minute
|
||||
) {}
|
||||
|
||||
async call<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const key = `circuit:${this.serviceName}`;
|
||||
const state = await this.kv.get(key, 'json') as {
|
||||
failures: number;
|
||||
lastFailure: number;
|
||||
} | null;
|
||||
|
||||
// Check if circuit is open
|
||||
if (state && state.failures >= this.threshold) {
|
||||
const elapsed = Date.now() - state.lastFailure;
|
||||
|
||||
if (elapsed < this.resetTime) {
|
||||
throw new NonRetryableError(
|
||||
`Circuit breaker open for ${this.serviceName}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
|
||||
// Reset on success
|
||||
await this.kv.delete(key);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Increment failure count
|
||||
const newState = {
|
||||
failures: (state?.failures || 0) + 1,
|
||||
lastFailure: Date.now()
|
||||
};
|
||||
|
||||
await this.kv.put(key, JSON.stringify(newState), {
|
||||
expirationTtl: this.resetTime / 1000
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
export class MyWorkflow extends WorkflowEntrypoint<Env, Params> {
|
||||
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
|
||||
const circuitBreaker = new CircuitBreaker(this.env.KV, 'external-api');
|
||||
|
||||
await step.do('call external API', async () => {
|
||||
return await circuitBreaker.call(async () => {
|
||||
const response = await fetch('https://external-api.example.com/data');
|
||||
return await response.json();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Pattern 3: Graceful Degradation
|
||||
|
||||
```typescript
|
||||
await step.do('fetch user preferences', async () => {
|
||||
try {
|
||||
const response = await fetch(`https://api.example.com/users/${userId}/preferences`);
|
||||
if (!response.ok) throw new Error('Failed to fetch preferences');
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch preferences, using defaults:', error);
|
||||
|
||||
// Fallback to defaults
|
||||
return {
|
||||
theme: 'light',
|
||||
language: 'en',
|
||||
notifications: true
|
||||
};
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Long-Running Process Patterns
|
||||
|
||||
### Pattern 1: Polling with Exponential Backoff
|
||||
|
||||
```typescript
|
||||
export class VideoProcessingWorkflow extends WorkflowEntrypoint<Env, VideoParams> {
|
||||
async run(event: WorkflowEvent<VideoParams>, step: WorkflowStep) {
|
||||
const { videoId } = event.payload;
|
||||
|
||||
// Submit video for processing
|
||||
const jobId = await step.do('submit video', async () => {
|
||||
const response = await fetch('https://processor.example.com/jobs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ videoId })
|
||||
});
|
||||
const data = await response.json();
|
||||
return data.jobId;
|
||||
});
|
||||
|
||||
// Poll for completion with exponential backoff
|
||||
let complete = false;
|
||||
let attempt = 0;
|
||||
const maxAttempts = 20;
|
||||
|
||||
while (!complete && attempt < maxAttempts) {
|
||||
// Wait with exponential backoff: 10s, 20s, 40s, ...
|
||||
const delay = Math.min(10 * Math.pow(2, attempt), 300); // Max 5 minutes
|
||||
await step.sleep(`wait attempt ${attempt}`, `${delay} seconds`);
|
||||
|
||||
const status = await step.do(`check status attempt ${attempt}`, async () => {
|
||||
const response = await fetch(
|
||||
`https://processor.example.com/jobs/${jobId}/status`
|
||||
);
|
||||
return await response.json();
|
||||
});
|
||||
|
||||
if (status.state === 'complete') {
|
||||
complete = true;
|
||||
} else if (status.state === 'failed') {
|
||||
throw new NonRetryableError(`Processing failed: ${status.error}`);
|
||||
}
|
||||
|
||||
attempt++;
|
||||
}
|
||||
|
||||
if (!complete) {
|
||||
throw new Error('Processing timeout after maximum attempts');
|
||||
}
|
||||
|
||||
return { videoId, jobId, status: 'complete' };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Pattern 2: Progress Tracking
|
||||
|
||||
```typescript
|
||||
export class DataMigrationWorkflow extends WorkflowEntrypoint<Env, MigrationParams> {
|
||||
async run(event: WorkflowEvent<MigrationParams>, step: WorkflowStep) {
|
||||
const { totalRecords, batchSize } = event.payload;
|
||||
const batches = Math.ceil(totalRecords / batchSize);
|
||||
|
||||
for (let i = 0; i < batches; i++) {
|
||||
const progress = await step.do(`migrate batch ${i}`, async () => {
|
||||
const offset = i * batchSize;
|
||||
|
||||
// Migrate batch
|
||||
await this.migrateBatch(offset, batchSize);
|
||||
|
||||
// Update progress in DB
|
||||
const percentage = Math.round(((i + 1) / batches) * 100);
|
||||
await this.env.DB.prepare(`
|
||||
UPDATE migration_jobs
|
||||
SET progress = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).bind(percentage, new Date().toISOString(), event.payload.jobId).run();
|
||||
|
||||
return { batch: i + 1, total: batches, percentage };
|
||||
});
|
||||
|
||||
console.log(`Progress: ${progress.percentage}%`);
|
||||
|
||||
// Small delay between batches to avoid overwhelming database
|
||||
if (i < batches - 1) {
|
||||
await step.sleep(`pause before batch ${i + 1}`, '1 second');
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 'complete', batches };
|
||||
}
|
||||
|
||||
private async migrateBatch(offset: number, limit: number) {
|
||||
// Migration logic
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Human-in-the-Loop Patterns
|
||||
|
||||
### Pattern 1: Approval with Timeout and Escalation
|
||||
|
||||
```typescript
|
||||
export class ApprovalWorkflow extends WorkflowEntrypoint<Env, ApprovalParams> {
|
||||
async run(event: WorkflowEvent<ApprovalParams>, step: WorkflowStep) {
|
||||
const { requestId, amount } = event.payload;
|
||||
|
||||
// Send to primary approver
|
||||
await step.do('notify primary approver', async () => {
|
||||
await this.sendApprovalRequest('manager@example.com', requestId);
|
||||
});
|
||||
|
||||
// Wait 48 hours for approval
|
||||
let approved: boolean;
|
||||
let approver: string;
|
||||
|
||||
try {
|
||||
const decision = await step.waitForEvent<ApprovalEvent>(
|
||||
'wait for primary approval',
|
||||
{ type: 'approval-decision', timeout: '48 hours' }
|
||||
);
|
||||
|
||||
approved = decision.approved;
|
||||
approver = decision.approverId;
|
||||
} catch (error) {
|
||||
// Timeout - escalate to senior manager
|
||||
console.log('Primary approval timeout, escalating');
|
||||
|
||||
await step.do('notify senior approver', async () => {
|
||||
await this.sendApprovalRequest('senior-manager@example.com', requestId);
|
||||
});
|
||||
|
||||
// Wait another 24 hours
|
||||
const escalatedDecision = await step.waitForEvent<ApprovalEvent>(
|
||||
'wait for escalated approval',
|
||||
{ type: 'approval-decision', timeout: '24 hours' }
|
||||
);
|
||||
|
||||
approved = escalatedDecision.approved;
|
||||
approver = escalatedDecision.approverId;
|
||||
}
|
||||
|
||||
if (approved) {
|
||||
await step.do('execute approved action', async () => {
|
||||
// Execute the action
|
||||
});
|
||||
}
|
||||
|
||||
return { requestId, approved, approver };
|
||||
}
|
||||
|
||||
private async sendApprovalRequest(to: string, requestId: string) {
|
||||
// Send notification
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workflow Chaining Patterns
|
||||
|
||||
### Pattern 1: Parent-Child Workflows
|
||||
|
||||
```typescript
|
||||
export class OrderWorkflow extends WorkflowEntrypoint<Env, OrderParams> {
|
||||
async run(event: WorkflowEvent<OrderParams>, step: WorkflowStep) {
|
||||
const { orderId } = event.payload;
|
||||
|
||||
// Step 1: Process payment (separate workflow)
|
||||
const paymentWorkflow = await step.do('start payment workflow', async () => {
|
||||
const instance = await this.env.PAYMENT_WORKFLOW.create({
|
||||
params: { orderId, amount: event.payload.amount }
|
||||
});
|
||||
return { instanceId: instance.id };
|
||||
});
|
||||
|
||||
// Step 2: Wait for payment to complete
|
||||
let paymentComplete = false;
|
||||
|
||||
while (!paymentComplete) {
|
||||
await step.sleep('wait for payment', '30 seconds');
|
||||
|
||||
const paymentStatus = await step.do('check payment status', async () => {
|
||||
const instance = await this.env.PAYMENT_WORKFLOW.get(
|
||||
paymentWorkflow.instanceId
|
||||
);
|
||||
return await instance.status();
|
||||
});
|
||||
|
||||
if (paymentStatus.status === 'complete') {
|
||||
paymentComplete = true;
|
||||
} else if (paymentStatus.status === 'errored') {
|
||||
throw new Error(`Payment failed: ${paymentStatus.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Start fulfillment workflow
|
||||
await step.do('start fulfillment workflow', async () => {
|
||||
await this.env.FULFILLMENT_WORKFLOW.create({
|
||||
params: { orderId }
|
||||
});
|
||||
});
|
||||
|
||||
return { orderId, status: 'processing' };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Pattern 1: Mock External APIs
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { unstable_dev } from 'wrangler';
|
||||
|
||||
describe('PaymentWorkflow', () => {
|
||||
let worker;
|
||||
|
||||
beforeEach(async () => {
|
||||
worker = await unstable_dev('src/index.ts', {
|
||||
experimental: { disableExperimentalWarning: true }
|
||||
});
|
||||
});
|
||||
|
||||
it('should process payment successfully', async () => {
|
||||
// Mock fetch to return success
|
||||
globalThis.fetch = async (url: string) => {
|
||||
if (url.includes('payment-gateway')) {
|
||||
return new Response(JSON.stringify({
|
||||
transactionId: 'TXN-123',
|
||||
status: 'success'
|
||||
}));
|
||||
}
|
||||
return new Response('Not found', { status: 404 });
|
||||
};
|
||||
|
||||
const response = await worker.fetch('/workflows/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
orderId: 'ORD-123',
|
||||
amount: 99.99
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.id).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Monitoring Patterns
|
||||
|
||||
### Pattern 1: Structured Logging
|
||||
|
||||
```typescript
|
||||
export class MyWorkflow extends WorkflowEntrypoint<Env, Params> {
|
||||
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
|
||||
this.log('info', 'Workflow started', {
|
||||
instanceId: event.instanceId,
|
||||
params: event.payload
|
||||
});
|
||||
|
||||
try {
|
||||
await step.do('process data', async () => {
|
||||
this.log('info', 'Processing data', { userId: event.payload.userId });
|
||||
// Process
|
||||
return { processed: true };
|
||||
});
|
||||
|
||||
this.log('info', 'Workflow completed successfully', {
|
||||
instanceId: event.instanceId
|
||||
});
|
||||
} catch (error) {
|
||||
this.log('error', 'Workflow failed', {
|
||||
instanceId: event.instanceId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private log(level: string, message: string, data: any) {
|
||||
console.log(JSON.stringify({
|
||||
level,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
...data
|
||||
}));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Pattern 2: Metrics Tracking
|
||||
|
||||
```typescript
|
||||
await step.do('track metrics', async () => {
|
||||
const metrics = {
|
||||
workflowId: event.instanceId,
|
||||
stepName: 'payment-processing',
|
||||
duration: performance.now() - startTime,
|
||||
status: 'success',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Store in Analytics Engine
|
||||
await this.env.ANALYTICS.writeDataPoint(metrics);
|
||||
|
||||
return metrics;
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices Summary
|
||||
|
||||
### Always Do
|
||||
|
||||
1. **Use idempotency keys** for external API calls
|
||||
2. **Categorize errors** - retry on transient failures, fail fast on terminal errors
|
||||
3. **Log structured data** - JSON logs for easy querying
|
||||
4. **Track progress** - update database for long-running processes
|
||||
5. **Use exponential backoff** - for polling and retries
|
||||
6. **Test workflows** - unit tests with mocked dependencies
|
||||
7. **Monitor metrics** - track success rates, durations, errors
|
||||
|
||||
### Never Do
|
||||
|
||||
1. **Don't retry non-idempotent operations infinitely** - use retry limits
|
||||
2. **Don't ignore timeout errors** - handle gracefully with fallbacks
|
||||
3. **Don't block on external events without timeout** - always set timeout
|
||||
4. **Don't assume steps execute in order** - each step is independent
|
||||
5. **Don't return non-serializable values** - only JSON-compatible types
|
||||
6. **Don't store sensitive data in workflow state** - use KV/D1 instead
|
||||
7. **Don't forget to clean up resources** - terminate unused workflow instances
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-10-22
|
||||
**Maintainer**: Jeremy Dawes | jeremy@jezweb.net
|
||||
Reference in New Issue
Block a user