diff --git a/docs/content/guides/migrate-cloudflare-workflows.mdx b/docs/content/guides/migrate-cloudflare-workflows.mdx new file mode 100644 index 0000000..d4fd374 --- /dev/null +++ b/docs/content/guides/migrate-cloudflare-workflows.mdx @@ -0,0 +1,260 @@ +--- +title: "Migrate from Cloudflare Workflows" +description: "Move Cloudflare Workflows to Rivet Workflows: map steps, retries, sleeps, and instance management, and see what has no direct equivalent yet." +--- + +import ExampleLinkBar from "@/components/docs/ExampleLinkBar.astro"; + + + +A Cloudflare Workflow instance becomes a Rivet workflow: a durable actor whose `run` function is built from replay-safe steps. Both runtimes record each step's result and replay it after a restart, so the programming model carries over: `step.do` becomes `ctx.step` and `step.sleep` becomes `ctx.sleep`. + +What changes is the shell around the steps. The instance ID is the workflow's [key](/actors/docs/keys), your code stores the input in the workflow's SQLite database, and you report status through actions and events that you define. + +## How concepts map + +| Cloudflare | Rivet | Notes | +| --- | --- | --- | +| `WorkflowEntrypoint` with `run(event, step)` | [`workflow({ run })`](/workflows/docs) | A workflow is an actor, so it can also have actions, events, and a database. | +| Instance ID | [Key](/actors/docs/keys) | `client.orderWorkflow.create([id])` | +| `event.payload` | Input to `onCreate` | Store it in SQLite and read it in a step. | +| `step.do(name, callback)` | [`ctx.step(name, callback)`](/workflows/docs/steps) | Results are recorded and replayed. | +| `WorkflowStepConfig` | `ctx.step({ maxRetries, retryBackoffBase, retryBackoffMax, timeout })` | Different defaults. See [Retries and timeouts](#retries-and-timeouts). | +| `NonRetryableError` | `CriticalError` | | +| `try`/`catch` around `step.do` | [`ctx.tryStep`](/workflows/docs/steps#handling-terminal-failures-as-data) or `ctx.try` | Failures come back as data. | +| `step.sleep`, `step.sleepUntil` | [`ctx.sleep`, `ctx.sleepUntil`](/workflows/docs/timers-and-concurrency) | Milliseconds only. | +| `Promise.all` over steps | [`ctx.join`](/workflows/docs/timers-and-concurrency#join) | `ctx.race` replaces `Promise.race`. | +| `env.MY_WORKFLOW.create({ id, params })` | `client.orderWorkflow.create([id], { input })` | | +| `instance.status()` | An action you define | Events can push progress instead. | +| `instance.terminate()` | An action that calls `c.destroy()` | Also deletes the workflow's data. | +| `step.waitForEvent`, `instance.sendEvent` | Queues, which are being deprecated | See [Waiting for events](#waiting-for-events). | +| `pause`, `resume`, `restart` | No equivalent | | +| `workflows` binding in Wrangler config | [`setup({ use })`](/actors/docs/registry-configuration) | | + +## Define a workflow + +Install the packages: + +```sh +npm add @rivet-dev/workflows rivetkit +``` + +A Cloudflare class with a `run` method becomes a `workflow()` definition. `ctx.key` holds the identifier you created it with, so a small value like an order ID can travel in the key: + +```ts Cloudflare @nocheck +import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers"; + +export class ShipmentWorkflow extends WorkflowEntrypoint { + async run(event: WorkflowEvent<{ orderId: string }>, step: WorkflowStep) { + await step.do("notify-warehouse", async () => { + await fetch("https://warehouse.example.com/shipments", { + method: "POST", + body: JSON.stringify({ orderId: event.payload.orderId }), + }); + }); + } +} +``` + + + +Register every workflow in one registry. This replaces the `workflows` entries in your Wrangler config: + + + +## Pass input + +Cloudflare hands `params` to `run` as `event.payload`. In Rivet, `onCreate` receives the input once, before `run` starts. Store it in the workflow's SQLite database, then read it in the first step: + +```ts Cloudflare @nocheck +type OrderInput = { orderId: string; email: string; amountCents: number }; + +export class OrderWorkflow extends WorkflowEntrypoint { + async run(event: WorkflowEvent, step: WorkflowStep) { + const { orderId, email, amountCents } = event.payload; + // ... + } +} +``` + + + + + +- Reading the input in a step records it, so every replay sees the same value. +- `event.timestamp` has no direct equivalent. Store `Date.now()` in `onCreate` if you need it, as the `created_at` column does here. + +## Retries and timeouts + +Step options move from a separate config argument onto the step itself: + +```ts Cloudflare @nocheck +const chargeId = await step.do( + "charge-card", + { + retries: { limit: 5, delay: "1 second", backoff: "exponential" }, + timeout: "10 minutes", + }, + async () => chargeCard(orderId, amountCents), +); +``` + + + +- `retries.limit` becomes `maxRetries`, which counts retries after the first attempt. +- `retries.delay` becomes `retryBackoffBase`. The first retry waits twice the base, and each later retry doubles the wait, up to `retryBackoffMax`. Rivet only has exponential backoff, so there is no `constant` or `linear` option. +- Durations are milliseconds. Duration strings like `"10 minutes"` are not accepted. +- The defaults are lower than Cloudflare's: 3 retries, a 100 ms base delay, and a **30 second timeout**. Set `timeout` on any step that can take longer, or `0` to disable it. +- A timeout fails the step right away. Set `retryOnTimeout: true` to retry it like any other error. +- A timeout doesn't stop the step's callback. With `retryOnTimeout`, the timed-out attempt can still be running when the retry starts, so the work must be safe to run twice. The example is safe because `chargeCard` sends the order ID as an idempotency key. + +## Stop retrying + +`NonRetryableError` becomes `CriticalError`. Both fail the step without retrying: + +```ts Cloudflare @nocheck +import { NonRetryableError } from "cloudflare:workflows"; + +await step.do("validate", async () => { + if (amountCents <= 0) { + throw new NonRetryableError("amount must be positive"); + } +}); +``` + + + +## Handle a failed step + +In Cloudflare, you catch a failed step with `try`/`catch`. In Rivet, `ctx.tryStep` returns the failure as data, and `ctx.try` does the same for a block of steps: + +```ts Cloudflare @nocheck +try { + await step.do("send-receipt", { retries: { limit: 3, delay: 1000 } }, async () => + sendReceipt(email, chargeId), + ); +} catch (error) { + await step.do("log-receipt-failure", async () => { + console.warn("receipt not sent", error); + }); +} +``` + + + +To undo earlier steps when a later one fails, use [rollback](/workflows/docs/failure-and-recovery#rollback) instead of hand-written cleanup steps. + +## Sleep + +`step.sleep` and `step.sleepUntil` map one to one. As in Cloudflare, compute any time inside a step, so replays get the same value: + +```ts Cloudflare @nocheck +const followUpAt = await step.do("follow-up-time", async () => Date.now() + 7 * 24 * 60 * 60 * 1000); +await step.sleep("wait-a-day", "1 day"); +await step.sleepUntil("wait-for-follow-up", followUpAt); +await step.do("send-reminder", async () => sendReminder(email)); +``` + + + +A long sleep doesn't hold a process. The workflow is saved and resumes when the timer is due. + +## Run steps in parallel + +Replace `Promise.all` over steps with `ctx.join`. Each branch is named and records its own steps: + +```ts Cloudflare @nocheck +const [fraudScore, labelId] = await Promise.all([ + step.do("score-fraud", async () => scoreFraud(orderId)), + step.do("create-label", async () => createLabel(orderId)), +]); +``` + + + +Use [`ctx.race`](/workflows/docs/timers-and-concurrency#race) where you used `Promise.race`. + +## Start a workflow + +Instead of a Worker binding, call the workflow through a RivetKit client from any server, script, or actor: + +```ts Cloudflare @nocheck +export default { + async fetch(request: Request, env: Env) { + const input = await request.json(); + const instance = await env.ORDER_WORKFLOW.create({ id: input.orderId, params: input }); + return Response.json({ id: instance.id }, { status: 202 }); + }, +}; +``` + + + +- Like `create` with an existing instance ID, `create` with an existing key fails. +- To start a workflow only if it doesn't exist yet, use `getOrCreate(key, { createWithInput })`. +- To start workflows on a schedule instead of a Cron Trigger, call `create` from an actor's [cron job](/guides/cron-jobs). + +## Check status and terminate + +Cloudflare tracks each instance's status for you. In Rivet, you record it in the workflow's database and expose it through actions: + +```ts Cloudflare @nocheck +const instance = await env.ORDER_WORKFLOW.get(id); +const { status, output, error } = await instance.status(); +await instance.terminate(); +``` + + + + + +The `complete` step records the result, and the `onError` hook records terminal failures: + + + + + + +- `c.destroy()` stops the workflow and deletes it along with its data. Cloudflare keeps a terminated instance's record, so copy anything you need elsewhere first. +- `onError` also sees attempts that will retry and failures that `tryStep` or `try` handle. Skip those, as the example does for `send-receipt`, so a recovered failure doesn't mark a running order as failed. +- `onError` only observes. Use it for status and alerts, not for work the workflow depends on. + +Steps can also broadcast [events](/actors/docs/events), so a client can follow progress live instead of polling. Connecting with `createWithInput` starts the workflow and subscribes in one call: + + + +## Start a child workflow + +A parent starts a child from inside a step, the same as in Cloudflare. `getOrCreate` makes the step safe to run again: + +```ts Cloudflare @nocheck +await step.do("start-shipment", async () => { + await this.env.SHIPMENT_WORKFLOW.create({ id: orderId, params: { orderId } }); +}); +``` + + + +As in Cloudflare, the parent doesn't wait for the child to finish. To check on the child, call one of its actions from a later step. + +## Waiting for events + +Delivering an external event to a running workflow, the job of `step.waitForEvent` and `instance.sendEvent`, currently goes through workflow queues. Queues are being deprecated, so don't build new code on them. + +Instead, a workflow can wait by checking for the event itself. An action writes the event into the workflow's SQLite database, and the workflow reads it in a `ctx.loop` that sleeps between checks. Keep the interval long enough that waiting doesn't wake the workflow more often than you need. + +## Differences to plan for + +- **Code outside steps runs on every replay.** The rules from Cloudflare still apply: name steps deterministically, keep side effects inside steps, and branch only on recorded values. +- **Changing in-flight workflows.** Cloudflare has no API like `getVersion` for changing the code of in-flight instances. In Rivet, use [`ctx.getVersion`](/workflows/docs/versioning) to change the steps of workflows that are already running. +- **No pause, resume, or restart.** Destroy the workflow and create a new one to start over. +- **Status is yours to define.** There is no built-in `queued`, `waiting`, or `errored` status. Store what your callers need, as in [Check status and terminate](#check-status-and-terminate). +- **Inspecting runs.** Instead of the Cloudflare dashboard and `wrangler workflows` commands, the inspector shows each workflow's step history. See [Debugging](/workflows/docs/reference#debugging). +- **Local development.** Replace `wrangler dev` with running your server, for example `npx tsx server.ts`. `registry.start()` runs workflows in the same process. + +## Next steps + +- [Steps](/workflows/docs/steps): how steps are recorded and replayed. +- [Timers & Concurrency](/workflows/docs/timers-and-concurrency): sleeps, timeouts, `join`, and `race`. +- [Failure & Recovery](/workflows/docs/failure-and-recovery): error hooks and rollback. +- [Versioning & Migrations](/workflows/docs/versioning): change workflows that are already running. diff --git a/docs/sidebar.json b/docs/sidebar.json index faca630..96930c1 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -75,6 +75,15 @@ "href": "/guides/webhook-delivery" } ] + }, + { + "title": "Migrations", + "pages": [ + { + "title": "From Cloudflare Workflows", + "href": "/guides/migrate-cloudflare-workflows" + } + ] } ] } diff --git a/examples/docs/guides/migrate-cloudflare-workflows/client.ts b/examples/docs/guides/migrate-cloudflare-workflows/client.ts new file mode 100644 index 0000000..19570a9 --- /dev/null +++ b/examples/docs/guides/migrate-cloudflare-workflows/client.ts @@ -0,0 +1,30 @@ +import { createClient } from "rivetkit/client"; +import type { OrderInput, Progress, registry } from "./order"; + +const client = createClient(); + +const input: OrderInput = { + orderId: crypto.randomUUID(), + email: "buyer@example.com", + amountCents: 4200, +}; + +// docs:start progress +// Connect with createWithInput, so the client subscribes as the workflow starts +// instead of after its first steps have already run. +const order = client.orderWorkflow.getOrCreate([input.orderId], { + createWithInput: input, +}); +const conn = order.connect(); + +await new Promise((resolve) => { + conn.on("progress", (event) => { + const { step } = event as Progress; + console.log("step", step); + if (step === "complete") resolve(); + }); +}); +// docs:end progress + +console.log(await order.getStatus()); +await conn.dispose(); diff --git a/examples/docs/guides/migrate-cloudflare-workflows/order.ts b/examples/docs/guides/migrate-cloudflare-workflows/order.ts new file mode 100644 index 0000000..352a237 --- /dev/null +++ b/examples/docs/guides/migrate-cloudflare-workflows/order.ts @@ -0,0 +1,219 @@ +import { CriticalError, event, setup, workflow } from "@rivet-dev/workflows"; +import { db } from "rivetkit/db"; +import { + chargeCard, + createLabel, + scoreFraud, + sendReceipt, + sendReminder, +} from "./tools"; + +export type OrderInput = { + orderId: string; + email: string; + amountCents: number; +}; +export type OrderStatus = "running" | "complete" | "failed"; +export type Progress = { step: string }; + +// One workflow per order. The key is the instance ID. +export const orderWorkflow = workflow( + { + // docs:start input + db: db({ + onMigrate: async (db) => { + await db.execute(` + CREATE TABLE IF NOT EXISTS run ( + id INTEGER PRIMARY KEY CHECK (id = 1), + input TEXT NOT NULL, + status TEXT NOT NULL, + output TEXT, + error TEXT, + created_at INTEGER NOT NULL + ) + `); + }, + }), + + // Runs once, before `run` starts. Store the input so steps can read it. + onCreate: async (c, input: OrderInput) => { + await c.db.execute( + "INSERT INTO run (id, input, status, created_at) VALUES (1, ?, 'running', ?)", + JSON.stringify(input), + Date.now(), + ); + }, + // docs:end input + + events: { + progress: event(), + }, + + run: async (ctx) => { + // docs:start load-input + const input = await ctx.step("load-input", async (step) => { + const rows = await step.db.execute<{ input: string }>( + "SELECT input FROM run WHERE id = 1", + ); + return JSON.parse(rows[0].input) as OrderInput; + }); + // docs:end load-input + + // docs:start critical + await ctx.step("validate", async () => { + // CriticalError fails the step right away, with no retries. + if (input.amountCents <= 0) { + throw new CriticalError("amount must be positive"); + } + }); + // docs:end critical + + // docs:start retries + const chargeId = await ctx.step({ + name: "charge-card", + maxRetries: 5, + retryBackoffBase: 1_000, + retryBackoffMax: 60_000, + timeout: 10 * 60_000, + retryOnTimeout: true, + run: async (step) => { + step.broadcast("progress", { step: "charge-card" }); + return await chargeCard(input.orderId, input.amountCents); + }, + }); + // docs:end retries + + // docs:start join + // Both branches run at the same time. Each branch records its own steps. + const checks = await ctx.join("checks", { + fraud: { + run: async (branch) => + await branch.step("score-fraud", async () => + scoreFraud(input.orderId), + ), + }, + label: { + run: async (branch) => + await branch.step("create-label", async () => + createLabel(input.orderId), + ), + }, + }); + // docs:end join + + // docs:start try-step + // tryStep returns failures as data instead of failing the workflow. + const receipt = await ctx.tryStep({ + name: "send-receipt", + maxRetries: 3, + run: async () => await sendReceipt(input.email, chargeId), + }); + if (!receipt.ok) { + await ctx.step("log-receipt-failure", async (step) => { + step.log.warn({ + msg: "receipt not sent", + error: receipt.failure.error.message, + }); + }); + } + // docs:end try-step + + // docs:start child + // Start a child workflow without waiting for it to finish. + await ctx.step("start-shipment", async (step) => { + const client = step.client(); + await client.shipmentWorkflow.getOrCreate([input.orderId]).resolve(); + }); + // docs:end child + + // docs:start complete + await ctx.step("complete", async (step) => { + const output = { chargeId, fraudScore: checks.fraud }; + await step.db.execute( + "UPDATE run SET status = 'complete', output = ?, error = NULL WHERE id = 1", + JSON.stringify(output), + ); + step.broadcast("progress", { step: "complete" }); + }); + // docs:end complete + + // docs:start sleep + // Compute times inside a step, so a replay sees the same value. + const followUpAt = await ctx.step( + "follow-up-time", + async () => Date.now() + 7 * 24 * 60 * 60 * 1000, + ); + await ctx.sleep("wait-a-day", 24 * 60 * 60 * 1000); + await ctx.sleepUntil("wait-for-follow-up", followUpAt); + await ctx.step("send-reminder", async () => sendReminder(input.email)); + // docs:end sleep + }, + + // docs:start status + actions: { + getStatus: async (c) => { + const rows = await c.db.execute<{ + status: OrderStatus; + output: string | null; + error: string | null; + }>("SELECT status, output, error FROM run WHERE id = 1"); + const row = rows[0]; + return { + status: row.status, + output: row.output ? JSON.parse(row.output) : null, + error: row.error, + }; + }, + // Stops the workflow and deletes it with its data. + terminate: (c) => { + c.destroy(); + }, + }, + // docs:end status + }, + { + // docs:start on-error + // Record terminal failures, so getStatus can report them. + onError: async (c, event) => { + // onError also sees attempts that will retry and failures that + // run handles with tryStep. Skip both. + if ( + "step" in event && + (event.step.willRetry || event.step.stepName === "send-receipt") + ) { + return; + } + const error = + "step" in event + ? event.step.error + : "rollback" in event + ? event.rollback.error + : event.workflow.error; + await c.db.execute( + "UPDATE run SET status = 'failed', error = ? WHERE id = 1", + error.message, + ); + }, + // docs:end on-error + }, +); + +// docs:start shipment +// A small identifier like the order ID can travel in the key instead of the input. +export const shipmentWorkflow = workflow({ + run: async (ctx) => { + const orderId = ctx.key[0]; + await ctx.step("notify-warehouse", async () => { + await fetch("https://warehouse.example.com/shipments", { + method: "POST", + body: JSON.stringify({ orderId }), + }); + }); + }, + actions: {}, +}); +// docs:end shipment + +// docs:start registry +export const registry = setup({ use: { orderWorkflow, shipmentWorkflow } }); +// docs:end registry diff --git a/examples/docs/guides/migrate-cloudflare-workflows/server.ts b/examples/docs/guides/migrate-cloudflare-workflows/server.ts new file mode 100644 index 0000000..6e6788e --- /dev/null +++ b/examples/docs/guides/migrate-cloudflare-workflows/server.ts @@ -0,0 +1,34 @@ +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { createClient } from "rivetkit/client"; +import { type OrderInput, registry } from "./order"; + +const client = createClient(); + +registry.start(); + +const app = new Hono(); + +// docs:start create +// Starts a workflow for the order and returns right away. +app.post("/orders", async (c) => { + const input = await c.req.json(); + // Fails with `actor.duplicate_key` if a workflow with this key already exists. + await client.orderWorkflow.create([input.orderId], { input }); + return c.json({ id: input.orderId }, 202); +}); +// docs:end create + +// docs:start get +app.get("/orders/:id", async (c) => { + const order = client.orderWorkflow.get([c.req.param("id")]); + return c.json(await order.getStatus()); +}); + +app.delete("/orders/:id", async (c) => { + await client.orderWorkflow.get([c.req.param("id")]).terminate(); + return c.body(null, 204); +}); +// docs:end get + +serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 3000) }); diff --git a/examples/docs/guides/migrate-cloudflare-workflows/tools.ts b/examples/docs/guides/migrate-cloudflare-workflows/tools.ts new file mode 100644 index 0000000..a4c00aa --- /dev/null +++ b/examples/docs/guides/migrate-cloudflare-workflows/tools.ts @@ -0,0 +1,44 @@ +// Stand-ins for your own APIs. + +export async function chargeCard( + orderId: string, + amountCents: number, +): Promise { + const res = await fetch("https://payments.example.com/charges", { + method: "POST", + headers: { "idempotency-key": orderId }, + body: JSON.stringify({ orderId, amountCents }), + }); + if (!res.ok) throw new Error(`charge failed: ${res.status}`); + return ((await res.json()) as { id: string }).id; +} + +export async function scoreFraud(orderId: string): Promise { + const res = await fetch(`https://fraud.example.com/score/${orderId}`); + return ((await res.json()) as { score: number }).score; +} + +export async function createLabel(orderId: string): Promise { + const res = await fetch("https://shipping.example.com/labels", { + method: "POST", + body: JSON.stringify({ orderId }), + }); + return ((await res.json()) as { labelId: string }).labelId; +} + +export async function sendReceipt( + email: string, + chargeId: string, +): Promise { + await fetch("https://email.example.com/send", { + method: "POST", + body: JSON.stringify({ to: email, template: "receipt", chargeId }), + }); +} + +export async function sendReminder(email: string): Promise { + await fetch("https://email.example.com/send", { + method: "POST", + body: JSON.stringify({ to: email, template: "review-reminder" }), + }); +}