UNPKG

workflow

Version:

Workflow SDK - Build durable, resilient, and observable workflows

252 lines (200 loc) • 11.9 kB
--- title: Transactions & Rollbacks (Saga) description: Coordinate multi-step transactions with automatic rollback when a step fails. type: guide summary: Run a sequence of steps where each registers a compensation. If any step throws a FatalError, compensations execute in reverse order to restore consistency. --- <CopyPrompt text="Implement a saga-style workflow. Import `FatalError` from `workflow`. In the exported &quot;use workflow&quot; function, keep a compensation stack such as `const compensations: Array<() => Promise<void>> = []`. For each forward side effect, create a &quot;use step&quot; helper, await it, then push the matching compensation &quot;use step&quot; helper onto the stack. Throw `FatalError` for permanent business failures and normal errors for retryable failures. In the workflow catch block, run compensations in reverse order with `for (const compensate of compensations.reverse()) await compensate()`, then rethrow or return a failed status. Make each compensation idempotent. Verify successful completion, a fatal failure after multiple steps, and a replay/retry during rollback." /> Use the saga pattern when a business transaction spans multiple services and you need automatic rollback if any step fails. Each forward step registers a compensation, and on failure the workflow unwinds them in reverse order. ## When to use this - Multi-service transactions (reserve inventory, charge payment, provision access) - Any sequence where partial completion leaves the system in an inconsistent state - Operations that need "all or nothing" semantics across external APIs ## How it works 1. Each forward step does work and registers a compensation function. 2. If any step throws `FatalError`, the catch block runs compensations in reverse, or last in, first out (LIFO), order to restore consistency. 3. Regular errors are retried automatically, up to three times by default. Use `FatalError` only for permanent failures where retrying won't help. ## Pattern Each step returns a result and pushes a compensation handler onto a stack. If a later step throws a `FatalError`, the workflow catches it and executes compensations in LIFO order. ```typescript import { FatalError } from "workflow"; declare function reserveSeats(accountId: string, seats: number): Promise<string>; // @setup declare function releaseSeats(accountId: string, reservationId: string): Promise<void>; // @setup declare function captureInvoice(accountId: string, seats: number): Promise<string>; // @setup declare function refundInvoice(accountId: string, invoiceId: string): Promise<void>; // @setup declare function provisionSeats(accountId: string, seats: number): Promise<string>; // @setup declare function deprovisionSeats(accountId: string, entitlementId: string): Promise<void>; // @setup declare function sendConfirmation(accountId: string, invoiceId: string, entitlementId: string): Promise<void>; // @setup export async function subscriptionUpgradeSaga(accountId: string, seats: number) { "use workflow"; const compensations: Array<() => Promise<void>> = []; try { const reservationId = await reserveSeats(accountId, seats); compensations.push(() => releaseSeats(accountId, reservationId)); // [!code highlight] const invoiceId = await captureInvoice(accountId, seats); compensations.push(() => refundInvoice(accountId, invoiceId)); // [!code highlight] const entitlementId = await provisionSeats(accountId, seats); compensations.push(() => deprovisionSeats(accountId, entitlementId)); // [!code highlight] // No compensation: notifications are fire-and-forget await sendConfirmation(accountId, invoiceId, entitlementId); return { status: "completed" }; } catch (error) { // Unwind compensations in reverse (LIFO) order for (const compensate of compensations.reverse()) { // [!code highlight] await compensate(); // [!code highlight] } return { status: "rolled_back" }; } } ``` ### Step functions Each step is a `"use step"` function with full Node.js access (fetch, fs, npm packages). Forward steps do the work and throw `FatalError` on permanent failure; compensation steps undo it and must be idempotent: safe to call multiple times if the workflow restarts mid-rollback. ```typescript import { FatalError } from "workflow"; // Forward steps async function reserveSeats(accountId: string, seats: number): Promise<string> { "use step"; const res = await fetch(`https://api.example.com/seats/reserve`, { method: "POST", body: JSON.stringify({ accountId, seats }), }); if (!res.ok) throw new FatalError("Seat reservation failed"); // [!code highlight] const { reservationId } = await res.json(); return reservationId; } async function captureInvoice(accountId: string, seats: number): Promise<string> { "use step"; const res = await fetch(`https://api.example.com/invoices`, { method: "POST", body: JSON.stringify({ accountId, seats }), }); if (!res.ok) throw new FatalError("Invoice capture failed"); // [!code highlight] const { invoiceId } = await res.json(); return invoiceId; } async function provisionSeats(accountId: string, seats: number): Promise<string> { "use step"; const res = await fetch(`https://api.example.com/entitlements`, { method: "POST", body: JSON.stringify({ accountId, seats }), }); if (!res.ok) throw new FatalError("Provisioning failed"); // [!code highlight] const { entitlementId } = await res.json(); return entitlementId; } async function sendConfirmation( accountId: string, invoiceId: string, entitlementId: string ): Promise<void> { "use step"; await fetch(`https://api.example.com/notifications`, { method: "POST", body: JSON.stringify({ accountId, invoiceId, entitlementId, template: "upgrade-complete" }), }); } // Compensation steps: must be idempotent async function releaseSeats(accountId: string, reservationId: string): Promise<void> { "use step"; await fetch(`https://api.example.com/seats/release`, { method: "POST", body: JSON.stringify({ accountId, reservationId }), }); } async function refundInvoice(accountId: string, invoiceId: string): Promise<void> { "use step"; await fetch(`https://api.example.com/invoices/${invoiceId}/refund`, { method: "POST", body: JSON.stringify({ accountId }), }); } async function deprovisionSeats(accountId: string, entitlementId: string): Promise<void> { "use step"; await fetch(`https://api.example.com/entitlements/${entitlementId}`, { method: "DELETE", body: JSON.stringify({ accountId }), }); } ``` ### Streaming step progress (optional) Use `getWritable()` to stream progress events to a user interface (UI) so users can see each step execute in real time. ```typescript import { FatalError } from "workflow"; import { getWritable } from "workflow"; type SagaEvent = | { type: "step_start"; step: string } | { type: "step_done"; step: string; detail: string } | { type: "step_failed"; step: string; error: string } | { type: "compensating"; step: string } | { type: "compensated"; step: string } | { type: "result"; status: "completed" | "rolled_back" }; async function emit(event: SagaEvent) { "use step"; const writer = getWritable<SagaEvent>().getWriter(); try { await writer.write(event); } finally { writer.releaseLock(); } } declare function reserveSeats(accountId: string, seats: number): Promise<string>; // @setup declare function releaseSeats(accountId: string, reservationId: string): Promise<void>; // @setup declare function captureInvoice(accountId: string, seats: number): Promise<string>; // @setup declare function refundInvoice(accountId: string, invoiceId: string): Promise<void>; // @setup declare function provisionSeats(accountId: string, seats: number): Promise<string>; // @setup declare function deprovisionSeats(accountId: string, entitlementId: string): Promise<void>; // @setup declare function sendConfirmation(accountId: string, invoiceId: string, entitlementId: string): Promise<void>; // @setup export async function subscriptionUpgradeSaga(accountId: string, seats: number) { "use workflow"; const compensations: Array<{ name: string; execute: () => Promise<void> }> = []; try { await emit({ type: "step_start", step: "Reserve Seats" }); const reservationId = await reserveSeats(accountId, seats); compensations.push({ name: "Release Seats", execute: () => releaseSeats(accountId, reservationId) }); await emit({ type: "step_done", step: "Reserve Seats", detail: reservationId }); await emit({ type: "step_start", step: "Capture Invoice" }); const invoiceId = await captureInvoice(accountId, seats); compensations.push({ name: "Refund Invoice", execute: () => refundInvoice(accountId, invoiceId) }); await emit({ type: "step_done", step: "Capture Invoice", detail: invoiceId }); await emit({ type: "step_start", step: "Provision Seats" }); const entitlementId = await provisionSeats(accountId, seats); compensations.push({ name: "Deprovision Seats", execute: () => deprovisionSeats(accountId, entitlementId) }); await emit({ type: "step_done", step: "Provision Seats", detail: entitlementId }); // No compensation: notifications are fire-and-forget await emit({ type: "step_start", step: "Send Confirmation" }); await sendConfirmation(accountId, invoiceId, entitlementId); await emit({ type: "step_done", step: "Send Confirmation", detail: "sent" }); await emit({ type: "result", status: "completed" }); return { status: "completed" }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; await emit({ type: "step_failed", step: "failed", error: errorMessage }); // Unwind compensations in reverse (LIFO) order for (const comp of compensations.reverse()) { await emit({ type: "compensating", step: comp.name }); await comp.execute(); await emit({ type: "compensated", step: comp.name }); } await emit({ type: "result", status: "rolled_back" }); return { status: "rolled_back" }; } } ``` ## Adapting to your use case - Replace the step functions with real API calls. Each `"use step"` function has full Node.js access. - Add or remove steps as needed; the pattern scales to any number of steps. - Make compensations idempotent, since they may be retried if the workflow restarts mid-rollback. - The `emit()` calls and `SagaEvent` type are optional; remove them if you don't need real-time UI progress. ## Tips - **Use `FatalError` for permanent failures**: Regular errors trigger automatic retries, up to three times by default. Throw `FatalError` when retrying won't help, such as for insufficient funds or invalid input. - **Make compensations idempotent**: If a compensation step is retried, it should produce the same result. Check whether the resource was already released before releasing it again. - **Use `"use step"` functions for compensation steps**: This makes them durable. If the workflow restarts during rollback, it resumes where it left off. - **Capture values in closures carefully**: Use block-scoped variables or copy values before pushing compensations to avoid referencing stale state. - **Skip compensations for notifications**: Fire-and-forget steps such as sending emails or Slack messages typically don't register a compensation. ## Key APIs - [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions): Declares the orchestrator function. - [`"use step"`](/docs/foundations/workflows-and-steps#step-functions): Declares step functions with full Node.js access. - [`FatalError`](/docs/api-reference/workflow/fatal-error): Represents a non-retryable error that triggers compensation. - [`getWritable()`](/docs/api-reference/workflow/get-writable): Streams data from workflows for real-time UI updates.