Agora
Authoring

Versioning & determinism

Keeping in-flight runs replay-safe across code changes — workflow versions for breaking changes (and pinning a start to one), the NonDeterminismError guard, the deterministic ctx.now and ctx.sideEffect capture sources, and ctx.patched for guarding an in-place change without a new version.

A durable run is replayed: when the engine resumes a suspended run, it re-executes the workflow body from the top and feeds each step its recorded checkpoint instead of running it again. For that to be correct, the body must take the same path it took originally — same steps, in the same order, at the same logical positions. The moment a code change shifts those positions, replay reads the wrong checkpoint into the wrong step. This page is about changing your workflow code without breaking the runs already in flight.

The non-determinism guard

The engine pairs each replayed step with the checkpoint recorded at that logical position. If the name at a position no longer matches what was recorded — you inserted a step, removed one, or reordered them — the engine throws a NonDeterminismError rather than silently corrupting the run.

// On replay, a checkpoint recorded as `charge` at this position but now named `refund` is caught:
// NonDeterminismError(runId, seq, expected: 'refund', recorded: 'charge')

The error carries runId and seq (the logical position) and has code: 'non_determinism'. Its message names both sides — what the code expects and what history recorded — and tells you to register a new workflow version. Because it marks a broken deploy rather than a business failure, an onError route never takes it.

This is a guard, not a fix. It tells you a code change is incompatible with runs that started under the old code. The two tools below let you make the change anyway — safely.

Workflow primitives inside a step body

A step body (ctx.localStep, ctx.transaction, ctx.sideEffect, ctx.now) runs once and is then replayed from its checkpoint without re-running. A ctx primitive called from inside one (ctx.startChild, ctx.child, ctx.step, ctx.sleep, ctx.sideEffect, …, or BaseWorkflow.start()/.dispatch() resolving the ambient ctx) would claim a journal position that replay never claims again. The engine refuses it at the call site with a NestedWorkflowCallError (a FatalError, code nested_workflow_call, carrying runId, primitive and step):

// ✗ throws NestedWorkflowCallError: the step starts a child
await ctx.localStep('plan', async () => {
  const plan = await planner.plan(input)
  await ExecutePlanWorkflow.dispatch(plan)
  return plan
})

// ✓ the step computes, the body orchestrates
const plan = await ctx.localStep('plan', () => planner.plan(input))
await ExecutePlanWorkflow.dispatch(plan)

Runs journaled before this guard existed (where the nested call is already recorded at that position) keep replaying unchanged. A dispatched step handler never sees the workflow ctx, so BaseWorkflow.dispatch() there starts a top-level run, including on in-process transports.

Deterministic sources — now & sideEffect

The most common way to break replay is reading a value that changes every time the body runs. A raw Date.now(), Math.random(), or crypto.randomUUID() returns a different value on each replay, which silently corrupts a durable run. Use the context's deterministic sources instead; each records its value on the first run and replays the same value afterwards:

engine.register('invoice', '1', async (ctx, order: Order) => {
  const issuedAt = await ctx.now()                              // epoch ms — captured once, replayed verbatim
  const nonce = await ctx.sideEffect(() => crypto.randomUUID()) // any generated value, captured once
  const sampled = (await ctx.sideEffect(() => Math.random())) < 0.1

  await ctx.localStep('issue', () => issue(order, { issuedAt, nonce, sampled }))
})

ctx.now() is the deterministic wall-clock and returns epoch milliseconds as a number, like Date.now() (for an ISO string, new Date(await ctx.now()).toISOString()); ctx.sideEffect(fn) runs fn once, checkpoints its result, and replays that same value without re-running fn — wrap any non-determinism you control (a UUID/ULID, Math.random(), a config/env read) in it. Each is captured the first time and returned from the checkpoint on every replay. Keep fn effectively pure: it produces a value, it is not a place for real work with side effects. For that (a DB write, an API call), use a full step, whose whole result is checkpointed. Reach for a raw Date.now()/Math.random()/crypto.randomUUID() only inside a step body (whose whole result is checkpointed) — never in the deterministic prefix of the workflow body.

Enable the @adonis-agora/durable-eslint-plugin so these mistakes are caught at author time, in your editor and CI, rather than at replay time.

Workflow versions — for breaking changes

When a change genuinely alters a workflow's shape — new steps, reordered logic, a different control flow — bump the version. Register the new version alongside the old:

// app/workflows/checkout_workflow_v1.ts
export default class CheckoutWorkflowV1 extends BaseWorkflow {
  static workflow = { name: 'checkout', version: '1' }
  async run(ctx: WorkflowCtx, order: Order) {
    /* the original body */
  }
}

// app/workflows/checkout_workflow_v2.ts
export default class CheckoutWorkflowV2 extends BaseWorkflow {
  static workflow = { name: 'checkout', version: '2' }
  async run(ctx: WorkflowCtx, order: Order) {
    /* the new body, with the breaking change */
  }
}

version defaults to '1' when omitted. The low-level form is the same thing — one engine.register per version:

engine.register('checkout', '1', async (ctx, order: Order) => {
  /* the original body */
})

engine.register('checkout', '2', async (ctx, order: Order) => {
  /* the new body, with the breaking change */
})

Every registration is kept, keyed by name@version, so old versions stay runnable. "Latest" is decided by comparing version strings — numerically when both parse as numbers ('10' is newer than '9'), otherwise by natural sort — not by registration order.

A run records the code version it started on (workflowVersion on the run). When the engine resumes it, it replays against the same version it began on — so in-flight runs drain on the code they started under, while new runs start on the latest version. This is skew protection: deploying v2 never breaks the v1 runs still in the system. Keep the old version registered until every run that started under it has reached a terminal state, then remove it.

If a run's starting version is no longer registered when it resumes, the engine refuses to replay it on a different body and throws workflow checkout@1 is not registered — keep the prior version deployed so in-flight runs can drain (skew protection). Execution timeouts (executionTimeout) follow the same rule: a run is timed out against the version it started on when that registration is known.

Starting a specific version

"Latest" is the default, not the only option. Pass version in the start options to run the exact version you mean — on engine.start or on a class's dispatch / start (whose options are StartOptions plus runId):

import engine from '@adonis-agora/durable/services/main'
import CheckoutWorkflowV2 from '#workflows/checkout_workflow_v2'

await engine.start('checkout', order, runId, { version: '1' }) // runs the v1 body, not v2
await CheckoutWorkflowV2.dispatch(order, { runId, version: '1' }) // same name, pinned to '1'

Reach for it when the caller records which version it ran — a pinned pipeline definition, a reproducible re-run of an old job. A version that is not registered throws workflow checkout@1 is not registered at start, before any run row is created; it never quietly falls back to the latest, because a caller that asked for checkout@1 and silently got checkout@2 has no way to notice. Pass a constant from inside a workflow body: computing the version at call time makes the body non-deterministic, exactly like reading the clock.

Pinning resolves against real registrations — a class's static workflow = { version }, engine.register, engine.registerRemote, engine.remote. It is deliberately not honoured by convention routing (an unregistered name routed to a live worker group of the same name), which invents its version: a pinned start there throws instead. To pin a cross-SDK workflow, register it for real with engine.remote(name, { group, version }) (see What can I call?).

Versioning is the right tool when the change is structural and you're prepared to keep both bodies around. For a small, surgical change, the version split is heavyweight — that's where ctx.patched comes in.

Patching in place — ctx.patched(id)

ctx.patched(id) guards an in-place change without a new version. Wrap the changed code in a branch on it:

import { FatalError } from '@adonis-agora/durable'

engine.register('checkout', '1', async (ctx, order: Order) => {
  const quote = await ctx.localStep('quote', () => price(order))

  if (await ctx.patched('add-fraud-check')) {
    // New behaviour: runs that started AFTER this code shipped take this branch.
    const risk = await ctx.step(scoreFraud, { orderId: order.id })
    if (risk.score > 0.9) throw new FatalError('high fraud risk', 'fraud')
  }

  await ctx.step(chargeCard, { orderId: order.id, amountCents: quote.total })
})

How it stays replay-safe:

  • A fresh run (started after the patch shipped) hits ctx.patched('add-fraud-check'), records a patch:add-fraud-check marker at that position, and returns true — it takes the new branch.
  • A run already recorded under the old code replays into this position and finds a real step there (the step that, in the old code, came next) — not a marker. So patched rewinds the logical position, gives it back to that old step, and returns false — the run keeps the old branch.
  • If the position holds a different patch marker (two ctx.patched calls with different ids swapped places), the engine throws NonDeterminismError instead of guessing.

The marker is position-transparent for old runs: because it rewinds rather than consuming a position, it never shifts an in-flight run's recorded checkpoints. New runs get the new path; old runs finish on the old path; neither is corrupted. Once every run that started under the old code has drained, remove the guard and keep just the new branch.

ctx.patched and workflow versions solve the same problem at different grain: a version is a whole second body for a structural change, a patch is a one-line guard for a surgical one.

1@Workflow({ name: 'checkout', version: '1' })2export default class CheckoutWorkflow {3  constructor(4    private pricing: PricingSteps,5    private fraud: FraudSteps,6    private payments: PaymentSteps,7  ) {}8 9  async run(ctx: WorkflowCtx, order: Order) {10    const quote = await ctx.step(this.pricing.quote, order)11 12    if (await ctx.patched('add-fraud-check')) {13      // NEW branch — only runs that started after this shipped enter here14      const risk = await ctx.step(this.fraud.score, { orderId: order.id })15      if (risk.score > 0.9) throw new FatalError('high fraud risk', 'fraud')16    }17 18    await ctx.step(this.payments.charge, { orderId: order.id, amountCents: quote.total })19  }20}
run A · started on the OLD coderun B · started AFTER the patchquotegate→falsechargedonequotegate→truefraudchargedone
completesRun A finishes on the old path, run B on the new — one codebase, two safe histories. Without the guard, run A would have hit a NonDeterminismError the moment the fraud step shifted its positions.
5 / 5

Tooling

Spotting a raw Date.now(), Math.random(), or crypto.randomUUID() in a workflow body is exactly the kind of mistake a linter should catch before it ships. The companion lint plugin flags non-deterministic calls inside workflow bodies and steers you to the deterministic sources. See Linting.

On this page

Partner

Your product as the official integration for Durable, right here.

Become a partner →