Workflow SDKs

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:

place_order.go
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:

MethodReturnsUse
Event()map[string]anyThe trigger event payload that started the run.
History()[]MemoizedStepThe recorded results of steps from prior invocations.
Context()context.ContextThe 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.

HelperSignature (Go)Opcode yieldedPurpose
Step[T]Step(ctx, name, func() (T, error)) (T, error)step.run / step.errorRun code once, capture its output.
SleepSleep(ctx, name, d time.Duration) errorsleepDurable timer for a relative delay.
SleepUntilSleepUntil(ctx, name, t time.Time) errorsleepDurable timer to an absolute time.
WaitForEvent[T]WaitForEvent(ctx, name, opts) (T, error)wait_for_eventPark until a matching event arrives.
WaitForSignal[T]WaitForSignal(ctx, name, opts) (T, error)wait_for_signalPark until a named signal is delivered.
InvokeInvoke(ctx, name, opts) (json.RawMessage, error)invoke_functionStart 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:

  1. It computes its deterministic ID from name and the current cursor index.
  2. It scans History() for a memoized record with that ID.
  3. On a hit — the step already ran on a prior invocation — it rehydrates the recorded value. Step[T] unmarshals the stored JSON Output into T and returns it; a recorded Error is returned as an error. Your function is never called.
  4. On a miss — this is the first time the step is reached — it runs your function, marshals the result, and yields a step.run opcode (or step.error if 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_complete

The 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.