Guides

Author a durable workflow

Long-running orchestration that survives process restarts, network partitions, and provider outages.

Workflows are the right tool when a single business operation spans multiple external calls, waits, retries, or compensations — and you need it to complete even if the process restarts halfway through. Vadyl's workflow engine journals every step, so on replay it skips work already completed and resumes from the next unfinished step.

Defining a workflow

// src/workflows/fulfillOrder.ts
import { workflow } from "@vadyl/sdk";


export default workflow.define("fulfillOrder", async (ctx, input: { orderId: string }) => {
  const order = await ctx.step("load-order", () =>
    ctx.entities.Order.read(input.orderId)
  );

  // Charge the customer
  const charge = await ctx.step("charge", async () => {
    return ctx.connections.stripe.createCharge({
      amount: order.total, currency: order.currency, customer: order.customerId,
    });
  });
  ctx.compensate(() => ctx.connections.stripe.refund({ id: charge.id }));

  // Reserve inventory
  await ctx.step("reserve", () =>
    ctx.connections.inventory.reserve({ orderId: order.id, items: order.items })
  );
  ctx.compensate(() =>
    ctx.connections.inventory.release({ orderId: order.id })
  );

  // Wait for the warehouse signal (durable wait — survives restart)
  const shipped = await ctx.waitForSignal<{ trackingNumber: string }>("shipped", {
    timeout: "72h",
  });

  // Update the order
  await ctx.step("mark-fulfilled", () =>
    ctx.entities.Order.update(order.id, {
      status: "fulfilled",
      trackingNumber: shipped.trackingNumber,
    })
  );

  // Notify the customer
  await ctx.step("notify", () =>
    ctx.connections.email.send({
      to: order.customerEmail, template: "shipped", data: { order, shipped },
    })
  );

  return { ok: true };
});

The three primitives

  • ctx.step(name, fn) — journaled, at-most-once per logical step. The first successful run is recorded; replays skip it and return the recorded result.
  • ctx.waitForSignal(name, opts) — durably suspends the workflow until an external signal arrives. The process can restart, scale down, or die — the wait survives.
  • ctx.compensate(fn) — registers a saga compensation. If the workflow fails after this point, registered compensations run in LIFO order to undo the prior side effects.

Determinism

Workflow code must be deterministic so replay can reproduce the history. Vadyl provides journaled accessors:

  • ctx.now() — replay-stable timestamp (records on first call, replays the same value)
  • ctx.random() — replay-stable random
  • ctx.uuid() — replay-stable UUID

Calling Date.now(), Math.random(), orcrypto.randomUUID() directly from inside a workflow is rejected at compile time.

Sending a signal

// From a webhook handler when the warehouse calls back:
await ctx.workflows.fulfillOrder.signal(workflowId, "shipped", {
  trackingNumber: req.body.tracking,
});

Pinning to a publication

Each workflow instance is pinned to the runtime publication version it started under. If you deploy a new version of the workflow code while an instance is running, the in-flight instance continues on the old version — guaranteeing replay determinism. New instances start under the new version. You can opt-in to migrate in-flight instances with an explicit migration step.

Failure and retry

Steps that throw are retried with exponential backoff per the workflow's retry policy. After exhausting retries, registered compensations run in reverse order (LIFO within priority). Each compensation is itself idempotent and journaled, so partial-failure recovery is safe.

Inspecting runs

vadyl runs list --workflow fulfillOrder
vadyl runs show <runId>          # full step-by-step history
vadyl runs replay <runId> --dry  # see what would happen on replay