Workflow-side API. use Hourglass.Workflow declares the module is a
workflow.
Execution model
Workflows execute via the pure-function Hourglass.Workflow.Evaluator.
Each activation runs the workflow body inline on an ephemeral evaluator
Task (spawned by Hourglass.WorkflowEvaluator.DynamicSupervisor)
with state threaded through arguments. The Workflow API primitives
consult a process-dict-keyed accumulator
(Hourglass.Workflow.CommandAccumulator) and either return a
resolved value or throw a sentinel that the evaluator catches.
No GenServer. No :persistent_term. No send + receive suspension.
Determinism comes from re-execution + sorted command_id ordering.
Command identification
Each command issued from workflow code is tagged with a command_id:
a tuple {child_index_path, local_seq} where:
child_index_pathis the lexicographic path identifying the issuing scope. The main workflow body has path[]. Direct async children get[0],[1], ... in the orderasync/1is called. Nested asyncs extend the path (e.g.[1, 0]is the first child of the second top-level child).local_seqis a monotonic counter local to the issuing scope, incremented on each command.
The evaluator accumulates commands by command_id, sorts by it at flush
time (lexicographic [] < [0] < [0,0] < [1] < ...), and assigns
monotonic global seqs in that deterministic order before sending to the
SDK. This removes BEAM-scheduling non-determinism from the externally
observable command sequence.
Summary
Functions
Spawn an asynchronous workflow scope.
Await the result of an async/1 scope. Returns the value the function
returned, or raises / rethrows if the scope failed.
Join a list of async/1 scopes, returning their values in order.
Block (suspend) the workflow until a signal named name has arrived, then
return its decoded payload.
Returns true if a cancel_workflow activation job has been delivered to
this workflow run, false otherwise.
Terminal: stops the workflow body and emits a ContinueAsNewWorkflowExecution
command carrying the serialised input. The Temporal server starts a new
run of the same workflow type with fresh history, passing input as the
initial argument.
Start a child workflow and await its result.
execute_child/3 that raises Hourglass.ChildWorkflowError instead of
returning {:error, reason}. Mirrors execute_activity!/3.
Per-activation workflow context.
Start a child workflow without awaiting its result. Suspends only until the
child has started, then returns its Hourglass.WorkflowHandle.
Functions
Spawn an asynchronous workflow scope.
async/1 runs fun.() inline within a child-path scope, swallowing any
suspend sentinel so sibling asyncs can also contribute commands. The
returned tag is opaque — pass it back to await/1.
The externally observable command_id sequence is governed by
child_index_path ++ [child_index].
Await the result of an async/1 scope. Returns the value the function
returned, or raises / rethrows if the scope failed.
Awaiting a scope whose body suspended (because it issued a command without a resolved result) re-throws the suspend sentinel — so the parent stops cleanly and the activation ships its accumulated commands.
Join a list of async/1 scopes, returning their values in order.
Equivalent to mapping await/1 over the list. If any scope has not yet
resolved (i.e. it suspended waiting for a command result), await/1
re-throws the suspend sentinel and the activation ships its accumulated
commands — correct fan-out behaviour.
Block (suspend) the workflow until a signal named name has arrived, then
return its decoded payload.
Signals are inbound signal_workflow activation jobs. The Nth call to
await_signal(name) in a re-execution consumes signals[name][N] (0-based).
The call counter resets at the start of every activation (via
CommandAccumulator.init/0) so each re-execution of the workflow body
deterministically re-consumes the same buffered signals.
When called with timeout: duration, races the signal against a durable
timer. Returns {:ok, payload} if the signal arrives first, or :timeout
if the timer fires first. The timer command_id is allocated unconditionally
every activation to preserve deterministic seq assignment.
Known limitation: if a completion is not acknowledged by the server and
the identical activation is redelivered after the signal has already been
persisted to state.signals, the signal would be appended a second time
(signals lack a seq-based idempotency key). Normal incremental and full-replay
paths are correct.
@spec cancelled?() :: boolean()
Returns true if a cancel_workflow activation job has been delivered to
this workflow run, false otherwise.
Non-blocking cooperative cancellation: the workflow checks cancelled?/0 at
safe points and decides what to do (e.g. skip remaining work and complete
gracefully). The flag is set during job ingestion and persists across
activations once set — once a cancel arrives it stays true.
Terminal: stops the workflow body and emits a ContinueAsNewWorkflowExecution
command carrying the serialised input. The Temporal server starts a new
run of the same workflow type with fresh history, passing input as the
initial argument.
Raises if called outside a workflow evaluator.
Start a child workflow and await its result.
Returns {:ok, result} with the child's output cast through its declared
__workflow_output_type__/0, or {:error, reason} where reason is one of:
{:start_failed, cause}— the child could not be started.causeis normally aCoresdk.ChildWorkflow.StartChildWorkflowExecutionFailedCauseatom — in practice:START_CHILD_WORKFLOW_EXECUTION_FAILED_CAUSE_WORKFLOW_ALREADY_EXISTS, the only non-unspecified value that enum defines. A start resolution whose shape this SDK does not recognise (e.g. a variant added by a newer Core) surfaces as{:unknown_child_start_resolution, raw}instead, so match defensively rather than assumingis_atom(cause). Kept distinct from a run failure on purpose: an "already exists" against a pinned singleton:idis usually expected rather than a fault.%Temporal.Api.Failure.V1.Failure{}— the child started and its run failed.{:cancelled, failure}— the child was cancelled.
Options
:id— the child's workflow id. Defaults to a deterministic derivation from{run_id, command_id}. Pin it for singleton/dedup semantics (pair withworkflow_id_reuse_policy: :reject_duplicate). A random nonce or a timestamp is illegal here — workflow bodies must be deterministic; useuuid/0if you want a readable-and-unique id.:task_queue— defaults to the parent's task queue.:parent_close_policy—:terminate(default) |:abandon|:request_cancel.:workflow_id_reuse_policy—:allow_duplicate(default) |:allow_duplicate_failed_only|:reject_duplicate|:terminate_if_running.:workflow_execution_timeout/:workflow_run_timeout/:workflow_task_timeout— millisecond integers.:retry_policy— same keyword shape as activities.
Fan out with the ordinary scopes — no child-specific machinery needed:
urls
|> Enum.map(fn u -> async(fn -> execute_child!(Ingest, %{"url" => u}) end) end)
|> await_all()
execute_child/3 that raises Hourglass.ChildWorkflowError instead of
returning {:error, reason}. Mirrors execute_activity!/3.
Note a raise inside a workflow body is a workflow-task failure (the run
parks and the server retries the task) — it is not a way to author a business
failure. Return execute_child/3's {:error, _} for that.
@spec info() :: Hourglass.Workflow.Info.t()
Per-activation workflow context.
Returns a %Info{} carrying the workflow's run_id + task_queue.
Reads from the evaluator state stamped on the process dict by
Hourglass.Workflow.Evaluator.run_body/2. Raises if no
evaluator is active on the calling process — info/0 is only
meaningful inside a workflow body during evaluation.
@spec random(pos_integer()) :: non_neg_integer()
@spec sleep(non_neg_integer() | {atom(), non_neg_integer()}) :: :ok
@spec start_child(module(), term(), keyword()) :: {:ok, Hourglass.WorkflowHandle.t()} | {:error, term()}
Start a child workflow without awaiting its result. Suspends only until the
child has started, then returns its Hourglass.WorkflowHandle.
:parent_close_policy is required — no default. Temporal's default is
TERMINATE, meaning a parent that completes right after a naive fire-and-forget
would silently kill the child mid-flight: no error, no result, just half-done
work. Both policies are legitimate for a detached child (an explorer wants
:abandon; a parent orchestrating a cancellable batch may want :terminate),
so this refuses to guess. Every other option matches execute_child/3.
{:ok, handle} = start_child(Ingest, %{"url" => url}, parent_close_policy: :abandon)The returned value is an ordinary Hourglass.WorkflowHandle naming the child's
id and run_id — useful for logging, or for returning out of the workflow so
client code can later Hourglass.result/2 / signal/3 / cancel/2 it.
What you must not do is call those client functions from inside a workflow body: they poll and perform network IO, which would break replay determinism. That restriction is about where you call them from, not about this handle. Acting on a running child from within its parent arrives with the deferred cancel/signal work.
@spec uuid() :: String.t()