Execution Primitives
The durable step-generator API — Step, Sleep, WaitForEvent, WaitForSignal, and Invoke — and how memoized replay makes side effects run exactly once.
An SDK workflow body is an ordinary function. In Go it has the signature
func(ctx ductor.Ctx) error; you register it on a Client and it becomes a
durable, replayable workflow without any orchestration code of your own:
client.Workflow("place-order", ductor.WorkflowOpts{
Trigger: ductor.Trigger{Event: "order.placed"},
}, func(ctx ductor.Ctx) error {
order, err := ductor.Step(ctx, "charge-card", func() (Receipt, error) {
return billing.Charge(ctx.Event())
})
if err != nil {
return err
}
if err := ductor.Sleep(ctx, "settle-delay", 24*time.Hour); err != nil {
return err
}
_, err = ductor.Step(ctx, "ship", func() (Shipment, error) {
return fulfil.Ship(order)
})
return err
})What makes this durable is not the function — it is the step helpers it calls. Each one is a generator: the first time control reaches it, it yields an opcode to the bridge and the invocation ends; on the next invocation it replays its recorded result and returns immediately. The function runs from the top every time, but each step executes exactly once.
The Ctx handle
Ctx is the per-invocation context handed to your workflow body
(sdks/go/ductor/ctx.go). It exposes exactly three things:
| Method | Returns | Use |
|---|---|---|
Event() | map[string]any | The trigger event payload that started the run. |
History() | []MemoizedStep | The recorded results of steps from prior invocations. |
Context() | context.Context | The underlying request context for cancellation and deadlines. |
You rarely touch History() directly — the step helpers read it for you. It is
the raw material of replay: a slice of MemoizedStep records, each carrying a
deterministic step ID, the originating Op, and either a JSON Output or an
Error.
The step primitives
Every helper takes the Ctx, a human-readable name, and produces (or replays)
a single opcode. The generic ones decode the replayed output into a type of your
choosing.
| Helper | Signature (Go) | Opcode yielded | Purpose |
|---|---|---|---|
Step[T] | Step(ctx, name, func() (T, error)) (T, error) | step.run / step.error | Run code once, capture its output. |
Sleep | Sleep(ctx, name, d time.Duration) error | sleep | Durable timer for a relative delay. |
SleepUntil | SleepUntil(ctx, name, t time.Time) error | sleep | Durable timer to an absolute time. |
WaitForEvent[T] | WaitForEvent(ctx, name, opts) (T, error) | wait_for_event | Park until a matching event arrives. |
WaitForSignal[T] | WaitForSignal(ctx, name, opts) (T, error) | wait_for_signal | Park until a named signal is delivered. |
Invoke | Invoke(ctx, name, opts) (json.RawMessage, error) | invoke_function | Start a child workflow run. |
Sleep, WaitForEvent, and WaitForSignal map onto the same durable
timer/signal machinery the engine uses for native
wait steps;
Invoke starts a child run the way a native subworkflow step does. The SDK is
just another way to express the same primitives.
Deterministic step IDs
Each helper computes a step ID before it does anything else. The ID is
sha256(name + ":" + index), truncated to its first 8 bytes and hex-encoded,
where index is the position of this step in the current invocation
(stepID in ctx.go):
func stepID(name string, index int) string {
h := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", name, index)))
return hex.EncodeToString(h[:8])
}The index is what keeps IDs stable across replays and unique when the same name appears twice. A cursor advances by one every time a step helper is reached, in program order — so as long as your workflow body reaches its steps in the same order each invocation, every step lands on the same ID it had before.
Determinism is a contract you keep
The ID scheme only works if the sequence of step calls is deterministic.
Branching on the wall clock, a random number, or unmemoized external state can
shift a step's index between invocations, break its ID, and re-run work that
already ran. Read those values inside a Step so the result is captured in
history and replayed identically.
Memoization and replay
Here is the mechanism, step by step. When a helper is reached:
- It computes its deterministic ID from
nameand the current cursor index. - It scans
History()for a memoized record with that ID. - On a hit — the step already ran on a prior invocation — it rehydrates the
recorded value.
Step[T]unmarshals the stored JSONOutputintoTand returns it; a recordedErroris returned as an error. Your function is never called. - On a miss — this is the first time the step is reached — it runs your
function, marshals the result, and yields a
step.runopcode (orstep.errorif the function failed). Yielding ends the invocation right there.
invocation 1: charge-card → MISS → run billing.Charge(), yield step.run, STOP
(engine persists the receipt to history)
invocation 2: charge-card → HIT → replay receipt, DO NOT re-charge
settle-delay → MISS → yield sleep, STOP
invocation 3: charge-card → HIT → replay receipt
settle-delay → HIT → timer elapsed, return
ship → MISS → run fulfil.Ship(), yield step.run, STOP
invocation 4: all HIT, function returns nil → run_completeThe card is charged once, in invocation 1. Every later invocation replays that receipt from history and moves on. That is what "durable" and "exactly once" mean here: the workflow function is re-executed many times, but each side effect happens exactly once because it is guarded by a memoized step.
How yielding works under the hood
Go has no generator syntax, so the SDK yields by panicking with an internal
sentinel that Workflow.Run recovers (safeCall in client.go). You never
see this — it is why a step helper "returns" by ending the whole invocation
rather than the next line of your function. A clean return from the function
yields the terminal run_complete opcode; a returned error yields
step.failed.
Related
Route & Workflow SDKs
Define durable, replayable workflows in your own codebase and host them at an HTTP bridge endpoint Ductor calls back into — in Go, TypeScript, or Python.
Bridge Protocol
The signed HTTP protocol between Ductor and your SDK — the five actions, the opcode wire format, HMAC signing, and the sync, checkpoint, and inline-run server endpoints.