5 min readResonate HQJust published

Saga with compensating actions in TypeScript on Resonate

How a saga collapses to a generator function with try/catch when every step is a Resonate durable checkpoint.

Resonate brand card on a dark background with a teal spectrum wave at the bottom and the post headline in white Sansation.

A trip booking workflow needs to either complete all three reservations (flight, hotel, car) or roll back the ones that already succeeded — the saga pattern. Resonate turns this into a generator function with try/catch, where every yield* ctx.run(...) is a durable checkpoint backed by a durable promise. This example runs end-to-end against the SDK's embedded LocalNetwork (in-memory only) — no Resonate server required to see the saga semantics; persistent crash-recovery requires running the same workflow against a real server with a stable id.

The shape of the solution

// imports + noRetry declaration omitted, see src/workflow.ts:1-16
// noRetry = { next: (attempt) => attempt === 0 ? 0 : null, encode: () => ({ type: "never", data: {} }) }
 
export function* bookTrip(
  ctx: Context,
  tripId: string,
  shouldFail: boolean,
): Generator<any, BookingResult, any> {
  // Note: code outside ctx.run() re-executes on replay.
  // All user-visible output lives inside the service functions (which are
  // wrapped in ctx.run) or in the entry point that reads the result.
 
  let flightId: string | undefined;
  let hotelId: string | undefined;
 
  try {
    // Step 1: Book flight
    flightId = yield* ctx.run(bookFlight, tripId);
 
    // Step 2: Book hotel
    hotelId = yield* ctx.run(bookHotel, tripId);
 
    // Step 3: Book car rental (this may fail)
    // noRetry ensures failure propagates immediately to trigger compensation
    // rather than retrying indefinitely with exponential backoff.
    const carId = yield* ctx.run(
      bookCarRental,
      tripId,
      shouldFail,
      ctx.options({ retryPolicy: noRetry }),
    );
 
    return { status: "success", tripId, flightId, hotelId, carId };
  } catch (error) {
    const message = (error as Error).message;
    const compensated: string[] = [];
 
    // Compensate in reverse order — each compensation is also durable
    if (hotelId) {
      yield* ctx.run(cancelHotel, tripId, hotelId);
      compensated.push("hotel");
    }
 
    if (flightId) {
      yield* ctx.run(cancelFlight, tripId, flightId);
      compensated.push("flight");
    }
 
    return { status: "failed", tripId, error: message, compensated };
  }
}
// from example-saga-booking-ts/src/workflow.ts:42-89

Setup is two lines; invocation is one call:

const resonate = new Resonate();
resonate.register(bookTrip);
 
// ...
 
const result = await resonate.run(
  `saga/${tripId}`,
  bookTrip,
  tripId,
  shouldFail,
);
// from example-saga-booking-ts/src/index.ts:8-9,23-28

The durable primitives in play

  • new Resonate() — constructs a client. With no url and no RESONATE_URL env var, the SDK falls through to LocalNetwork, an in-memory backend that keeps durable promises in a Map — fine for demonstrating saga semantics, but it does not survive a process crash (src/index.ts:8; SDK src/resonate.ts:101-171; SDK src/network/local.ts:139-146).
  • resonate.register(bookTrip) — registers the generator function under its name so it can be invoked and replayed by id (src/index.ts:9; SDK src/resonate.ts:253-294).
  • resonate.run(id, bookTrip, ...args) — creates a durable promise keyed by `saga/${tripId}` and drives the generator to completion. Calling it again with the same id, within a process whose backend still holds that promise, returns the existing promise (src/index.ts:23; SDK src/resonate.ts:296-299).
  • yield* ctx.run(fn, ...args) — a local function call that becomes a durable checkpoint: the result of bookFlight/bookHotel/bookCarRental is persisted to the backend, so on replay the function is not re-invoked, the prior return value is replayed (src/workflow.ts:56,59,64; SDK src/context.ts:208-209).
  • ctx.options({ retryPolicy: noRetry }) — attaches per-call options, in this case overriding the default retry policy on the car rental step (src/workflow.ts:68; SDK src/context.ts:244).
  • noRetry — an inline retry policy whose next(attempt) returns 0 on the first attempt and null afterward, encoded as { type: "never", data: {} }. Behaviorally equivalent to the SDK's built-in Never policy — a duck-typed object literal in this example, where the SDK ships a class (src/workflow.ts:13-16; SDK src/retries.ts:118-135).

What the SDK handles vs. what you write

You writeThe SDK handles
The generator bookTrip with try/catch and reverse-order compensationPersisting the result of every yield* ctx.run(...) as a durable promise in the configured backend
The three booking calls (bookFlight, bookHotel, bookCarRental) and their inverses (cancelHotel, cancelFlight)Replaying completed steps from their persisted results instead of re-invoking them on the same execution
The decision that the car rental step should fail-fast instead of retrying (retryPolicy: noRetry)Applying the default Exponential retry policy to non-generator steps, and applying noRetry only where you asked (SDK src/context.ts:384,418)
The invocation id `saga/${tripId}`Keying execution by id — within a process whose backend still holds the promise, a second resonate.run with the same id attaches to the existing durable promise

The load-bearing claim: the executable code in src/workflow.ts contains no checkpoint bookkeeping, no state machine, and no compensation table. The saga semantics fall out of yield* + try/catch. (The file's leading comments do mention those terms — they describe what the SDK is doing on your behalf, not what the function body is implementing.)

Failure modes covered

This example runs against the SDK's embedded LocalNetwork, which stores durable promises in memory only — a process crash erases the backend, so the crash-recovery claims below apply when this same workflow runs against a real Resonate server with persistent storage and the caller passes a stable id (not `trip-${Date.now()}` recomputed at process start).

What this example, as shipped, demonstrates by running bun start:fail:

  • Car rental throws. With shouldFail === true, bookCarRental raises Error("Car rental unavailable: no cars at this location"). Because the step is configured with noRetry, the error is not retried — it propagates out of yield* ctx.run(...) and is caught by the try/catch in bookTrip, which then runs the compensation branch (src/services.ts:75, src/workflow.ts:64-72).
  • Compensation order. cancelHotel runs before cancelFlight — the last successful booking is the first to be reversed. Each compensation is itself a yield* ctx.run(...) so its return is also persisted to the backend (src/workflow.ts:77-85).
  • Selective retry behavior. bookFlight and bookHotel are async (non-generator) functions, so they inherit the SDK's default Exponential retry policy; only bookCarRental opts into noRetry. A transient failure on flight or hotel would be retried; a failure on car rental immediately enters compensation (SDK src/context.ts:384,418).

What additionally holds when the same workflow runs against a Resonate server with persistent storage and a stable id:

  • Process crash between bookFlight and bookHotel — on restart, resonate.run is called with the same `saga/${tripId}` id. The flight step's persisted result is replayed; execution resumes at bookHotel (src/workflow.ts:59).
  • Process crash between bookHotel and bookCarRental — both prior steps replay from their persisted results, the car rental step runs fresh (src/workflow.ts:64).
  • Process crash mid-compensation, after cancelHotel but before cancelFlightcancelHotel's success is already a persisted checkpoint, so the saga replays through it and proceeds directly to cancelFlight — the hotel is not double-cancelled (src/workflow.ts:78,83).
  • Caller invokes resonate.run twice with the same id — the id keys a single durable promise; the second call attaches to the existing promise rather than starting a parallel saga. Requires a stable id from the calling system (e.g. an order id from your DB), not `trip-${Date.now()}`.

The example does not handle: a cancelHotel / cancelFlight call that itself throws (the default retry policy on those steps will retry on failure, but the example does not demonstrate a permanent compensation failure or a dead-letter path).

When to reach for this pattern

  • If you need to make a multi-step booking, payment, or provisioning workflow all-or-nothing against external services that have no shared transaction.
  • If your "rollback" is actually a sequence of compensating API calls (cancelHotel, refundCharge, releaseInventory) rather than a database rollback.
  • If a crash mid-rollback would otherwise leave the system in an inconsistent state — and you do not want to write a separate recovery worker for partially-completed compensations (note: this property requires running against a Resonate server with persistent storage, not the embedded LocalNetwork this example uses by default).
  • If you want one specific step to fail-fast (skip retries) so that compensation fires immediately, while other steps still get the default retry behavior.
  • If you want the saga to be readable as straight-line code in code review, without a separate state machine or framework DSL.

Sources