Skip to content

Steps and determinism

The step primitives, how steps are identified, and the rules your workflow code follows.

Updated View as Markdown

A step is a named unit of work whose outcome EZGH records once. On every later invocation, the step returns its recorded outcome without running again.

Primitives

TypeScript Go Python What it does
step.run(name, fn, opts?) workflows.Run(ctx, name, fn, opts...) ctx.step.run(name, fn, ...) Runs fn at least once and records its JSON result
step.sleep(name, "3d"), step.sleepUntil(name, at) workflows.Sleep(ctx, name, d), workflows.SleepUntil(ctx, name, t) ctx.step.sleep(name, "3d"), ctx.step.sleep_until(name, when) A durable timer, at most a year
step.now(name), step.uuid(name) workflows.Now(ctx, name), workflows.UUID(ctx, name) ctx.step.now(name), ctx.step.uuid(name) A time or UUID, recorded once and the same on every replay
step.createCallback(name, opts?) workflows.CreateCallback(ctx, name, timeout) ctx.step.create_callback(name, ...) A URL that completes the step (Callbacks)
step.invoke(name, { workflow, input }) workflows.Invoke[T](ctx, name, workflow, input) ctx.step.invoke(name, workflow=…, input=…) Runs a child workflow and waits for it (Child workflows)

Steps started together run in parallel: see Parallel steps.

import { workflow } from "@ezgh/workflows"

export const onboarding = workflow("onboarding", async ({ input, step }) => {
  const user = await step.run("load-user", () => db.users.get(input.userId))
  await step.run("welcome-email", () => email.send(user.email, "Welcome!"))
  await step.sleep("cooldown", "3d")
  return step.run("follow-up", () => email.send(user.email, "How is it going?"))
})

How steps are identified

  • A step is identified by its name and how many times the name was used before in the run. A loop calling step.run("send", …) three times gets three distinct steps: the first, second and third send.
  • Names are 1 to 256 bytes of UTF-8, without NUL characters.
  • An explicit ID ({ id } in TypeScript, WithID in Go, id= in Python) names one step for the whole run, and matches ^[A-Za-z0-9._:-]{1,128}$. Using the same explicit ID twice fails the run with workflows.duplicate_step_name.
  • Steps pending at the same time need distinct names (or explicit IDs). Otherwise the run fails with workflows.duplicate_step_name.

Because of the counter, removing or adding a step with a repeated name shifts the later ones: if you delete the first of three send steps from your code while runs are in progress, the second send of those runs gets the recorded result of the old first one. Give steps in loops explicit IDs, such as send-${recipient.id}, when the code may change while runs are in progress.

Values are JSON

A step’s result, a workflow’s input and output, and a callback’s body are JSON, at most 1 MiB each. The SDK passes a step’s result through JSON before returning it, so the first execution and every replay see the same value:

  • TypeScript: a Date becomes an ISO string, and undefined becomes null. A function, symbol or bigint fails the step without retrying.
  • Go: the result is encoded to JSON and decoded into the step’s type T.
  • Python: dict with string keys, list, tuple (as a list), str, int, finite float, bool, None, an aware datetime and a date. Anything else, such as a naive datetime, Decimal, set or bytes, fails the step without retrying.

A result over 1 MiB fails the step with workflows.output_too_large, without retrying. Keep large data elsewhere and return a reference to it.

Rules for workflow code

  • Code outside steps runs again on every invocation, so it must have no side effects and take the same path every time. Put every call to another system in a step.
  • Use the now and uuid steps instead of the clock and random numbers.
  • In Go, call steps only from the workflow’s goroutine, or from a Parallel, All or Race branch with the branch’s context, and don’t depend on map iteration order. When an invocation suspends (at a sleep, a wait or parallel steps), the workflow’s goroutine ends, so its deferred functions run.
  • In Python, an async def workflow runs on an event loop of its own in a dedicated thread, and must not await objects bound to your server’s loop outside steps. A plain def workflow runs in a dedicated thread, and its step calls block.
  • A step’s function can’t call steps. Calling one from inside a step’s function is an error.

Inside a step, the step’s context gives its attempt number and an idempotency key; see Retries and idempotency.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close