Workflow guide¶
Status: Phase 4. The workflow API is preview API and lives in the core package — the
http,database,ollama, anddurableextras are not needed and are never imported.
Three ways to run work¶
Xyberos keeps the distinction explicit, because the promises differ:
| Mode | Use it for | Guarantees | Does not promise |
|---|---|---|---|
Direct call (await direct(...)) |
one function is the whole task | context, deadline, cooperative cancellation; the callable's own exception propagates unchanged | retries, inspection, branching |
Workflow (await workflow.run(...)) |
multi-step work you want to inspect, retry, branch, and observe | validated graph, bounded retries, per-step and whole-run deadlines, structured events, no orphan tasks | persistence, recovery, parallel steps |
Durable execution (xyberos.durable) |
multi-step work that must outlive the process, on one host | persisted progress, fenced single-owner claims, recovery, cancellation, inspection; experimental | exactly-once effects, multi-host ownership, arbitrary Python checkpointing |
An ordinary HTTP handler does not need a workflow. Reach for one when the work is multi-step and you want the steps to be visible, bounded, and testable without a live provider.
Defining a workflow¶
A definition is an immutable value: every builder call returns a new Workflow, and nothing is
mutated once validation succeeds.
from xyberos import Backoff, Retry, StepContext, Workflow, seconds
async def classify(value: dict[str, str], context: StepContext) -> dict[str, str]:
response = await service.call(ticket=value["ticket"])
return {"ticket": value["ticket"], "category": str(response["category"])}
async def refund(value: dict[str, str], context: StepContext) -> str:
return f"refund queued for {value['ticket']}"
async def escalate(value: dict[str, str], context: StepContext) -> str:
return f"escalated {value['ticket']}"
workflow = (
Workflow("ticket-triage", version="1", input_type=dict)
.step("classify", classify, retry=Retry(max_attempts=3, backoff=Backoff(initial=0.05)))
.step("refund", refund, timeout=seconds(5))
.step("escalate", escalate, timeout=seconds(5))
.edge("classify", "refund", when=lambda value: value["category"] == "refund")
.edge("classify", "escalate")
)
result = await workflow.run(input={"ticket": "T-100"}, timeout=seconds(30))
| Member | Purpose |
|---|---|
Workflow(name, version=...) |
the definition; version is required data because a durable executor records what it ran |
input_type |
optional class (or tuple of classes) checked with isinstance before the first step |
.step(id, run, retry=, timeout=, metadata=) |
append a step |
.edge(source, target, when=) |
connect two steps and carry the source's output to the target |
.with_step(...) / .with_edge(...) |
append an already-built Step or Edge, for definitions assembled from data |
metadata |
JSON-serializable, secret-free definition data for inspection and later phases |
Step.timeout is in seconds and bounds one attempt. Workflow.run(timeout=...) is the budget
for the whole run. Both are optional; public policies take durations, and the execution context
exposes the absolute deadline they derive.
Validation¶
workflow.validate() is idempotent and runs automatically before any execution. It never calls a
step function or a branch predicate, so validating and inspecting are free of side effects. A
definition is rejected when:
- the name or version is empty, or the name is not an identifier;
- there are no steps, a step id is empty, or a step id is duplicated;
- a step function is not an
async def, or accepts fewer than two positional parameters (the declared input and the context); - a step timeout is not greater than zero, or
retryis not aRetrypolicy; - an edge names a step that does not exist, duplicates another edge, or is a self-loop;
- a step has more than one edge without a condition, or a conditioned edge is declared after that unconditioned fallback edge;
- a branch condition is missing, not callable, asynchronous, or does not take one argument;
- the graph contains a cycle (the message names the path), because execution is sequential and conditional only;
- an edge is incompatible with the declared types of its endpoints, or entry steps disagree about
the input they declare and
input_typedoes not match them; input_typeis not usable withisinstance, or metadata is not JSON-serializable with string keys.
Because a definition must be non-empty and acyclic, every step is reachable from an entry step and can reach an exit step, so no separate reachability rule is needed.
Execution semantics¶
Execution is deterministic and sequential. A step becomes ready once every step feeding it has resolved, and steps are considered in declaration order.
- Entry steps (no incoming edge) receive the run's
input. - A reached step receives the output of the one branch that reached it.
- A step no branch reached is skipped, is reported as
skipped, and produces no output. - Exactly one edge leaves a step: the first conditioned edge whose predicate holds, or the unconditioned fallback if none does. If a step declares only conditions and none matches, its targets are skipped.
- Two branches reaching one step is a definition bug: the run fails with a
WorkflowExecutionErrornaming both sources. Branches that merge must be mutually exclusive.
A step may be a plain async function with its own dependencies closed over, which keeps a workflow's inputs visible in the definition.
Retries¶
Retry(max_attempts=3, retry_on=(TransientError,), backoff=Backoff(initial=0.1, multiplier=2.0, max_delay=5.0, jitter=0.0))
max_attempts counts the first try, so max_attempts=1 means no retrying. Classification is
policy driven:
- raise
xyberos.TransientError(or a subclass) from a step to be retried by the default policy; - widen or narrow the policy with
retry_on=(...), which selects by exception type; - a step timeout is not retryable by default because
StepTimeoutErroris not aTransientError; retrying a hung operation has to be explicit. - programming errors (
ValueError,KeyError, …) are never retried silently.
The delay before the attempt after attempt n is initial * multiplier ** (n - 1), capped at
max_delay. jitter is the fraction of that delay randomisation may remove, so a delay is never
longer than the computed value and never shorter than 1 - jitter of it. Jitter draws from the
executor's random source, which tests seed.
Retrying stops immediately on cancellation or when the run's deadline passes, and every attempt outcome is preserved in order:
RetryExhaustedError: step 'fetch' failed after 3 attempt(s) with RateLimitError
attempts: ['failed', 'failed', 'failed']
cause: RateLimitError
A step's side effect may therefore happen more than once. Idempotency is the caller's responsibility for any side-effecting step.
Timeouts, deadlines, and cancellation¶
- A step's
timeoutapplies per attempt; a retried step gets a fresh budget each time. Workflow.run(timeout=...)bounds the whole run. It is enforced while a step runs and before the next step starts, so a step that overshoots still finishes its work, and the run then fails without starting anything new.- When a step timeout and the run deadline are reached together, the run deadline wins.
- Inside a step,
context.remaining(),context.expired(), andruntime.get_deadline()expose the absolute deadline;context.check()raises if cancellation was requested or time is up.
Cancellation is cooperative. Request it with a token:
from xyberos import CancellationToken
token = CancellationToken()
result = await workflow.run(input=data, cancellation=token)
token.cancel("the request was aborted")
- A cancellation requested before a step starts prevents that step from starting; the run fails with
WorkflowCancelledErrornaming that step. - A cancellation during a step cancels the step's task and cancels the token the step was given, so
a step that awaits cooperatively observes
asyncio.CancelledErroror the token. A step that ignores both keeps running to completion: stopping it is best effort and depends on the step cooperating. - A step can stop the whole run by raising
WorkflowCancelledErrororWorkflowTimeoutError.
Events¶
Pass on_event= a sink to receive structured, sequenced events. Sinks run on the execution path and
must not block.
#1 workflow_started workflow=ticket-triage steps=3
#2 step_started workflow=ticket-triage step=classify attempt=1
#3 step_failed workflow=ticket-triage step=classify attempt=1 duration=0.000000 error=RateLimitError
#4 step_retried workflow=ticket-triage step=classify attempt=1 retrying in 0.05s
#5 step_started workflow=ticket-triage step=classify attempt=2
#6 step_succeeded workflow=ticket-triage step=classify attempt=2 duration=0.000000
#7 step_started workflow=ticket-triage step=refund attempt=1
#8 step_succeeded workflow=ticket-triage step=refund attempt=1 duration=0.000000
#9 step_skipped workflow=ticket-triage step=escalate no branch reached this step
#10 workflow_succeeded workflow=ticket-triage duration=0.050000 outputs=1
| Event | Emitted when |
|---|---|
workflow_started, workflow_succeeded, workflow_failed, workflow_timed_out, workflow_cancelled |
the run begins and ends, with its duration |
step_started, step_succeeded, step_failed |
each attempt begins and ends |
step_retried |
a failure is retryable and attempts remain |
step_skipped |
no branch reached the step |
step_timed_out |
the step's own timeout, or the run deadline, stopped the attempt |
step_cancelled |
the attempt was started and then cancelled |
Events carry identifiers, status, attempt numbers, durations, timings from the executor's clock, and the caller's correlation ID. They never carry step input or output, and never exception messages, because both routinely contain personal or secret data. The raised error and the step's own logging are where details belong. A step that never started produces no step event; the raised error and the workflow-level event name it.
Inspection¶
workflow.render() describes nodes, edges, policies, and definition version without executing
anything, so it is safe in logs, tests, and documentation:
workflow 'ticket-triage' version='1'
input: dict
exits: refund, escalate
step classify [entry] run=classify retry(max_attempts=3, retry_on=(TransientError), backoff(initial=0.05, multiplier=2, max_delay=5, jitter=0))
step refund [exit] run=refund timeout=5s
step escalate [exit] run=escalate timeout=5s
edge classify -> refund when=<lambda>
edge classify -> escalate
A successful run returns a WorkflowResult:
outputsmaps each exit step that ran to its output;valuereturns the single output and raises if the run produced none or several;stepsmaps every resolved step id to aStepOutcomewith its status and every attempt, andoutcome(step_id)returns one of them;durationis the run's elapsed time on the executor's clock.
A completed run reports every declared step, so outcome(...) raises on an unknown id — a caller
mistake. result.steps.get(step_id) is the lookup that tolerates absence.
Errors¶
| Error | Meaning |
|---|---|
WorkflowValidationError |
the definition (or a policy, or a timeout argument) is invalid; nothing ran |
WorkflowExecutionError |
a step failed; .step, .attempts, and .outcomes carry the context, and the original exception is the cause |
RetryExhaustedError |
the retry policy used every attempt |
StepTimeoutError |
one step attempt exceeded its timeout |
WorkflowTimeoutError |
the run exceeded its deadline |
WorkflowCancelledError |
cancellation was requested before or during execution |
TransientError |
base class a step raises to opt a failure into the default retry classification |
Testing¶
The core package ships deterministic doubles, so retries, deadlines, and cancellation are testable without wall-clock sleeps:
ManualClock— virtual time.sleepregisters a sleeper instead of waiting,pendingandsleepsreport what was requested,advance(delta)andadvance_to_next()move time, andwait_for_sleepers(n)reaches the moment the executor is waiting on a deadline.drive(task)runs a workflow to completion, moving time whenever nothing else can progress; usewait_for_sleepers/advancewhenever a test asserts exactly how much time passed.FakeExternalService+Reply— a scripted service covering latency (Reply.slow), rate limits (Reply.rate_limited), transient failures (Reply.transient), malformed payloads (Reply.malformed), and a cancellation-aware call. Replies play in order and the last one repeats.pending_tasks()— assert that no execution left a task behind.
clock = ManualClock()
service = FakeExternalService([Reply.rate_limited(), Reply.ok("done")], clock=clock)
workflow = Workflow("demo").step(
"fetch", fetch, retry=Retry(max_attempts=3, backoff=Backoff(initial=0.5))
)
task = asyncio.ensure_future(
workflow.run(executor=WorkflowExecutor(clock=clock, random=Random(1)))
)
result = await clock.drive(task)
assert result.value == "done" # succeeded on the second attempt
assert clock.sleeps == (0.5,) # exactly one backoff delay was requested
Deliberate limitations¶
- No persistence or recovery. State is lost when the process exits, and execution events are not a durable journal. Durability is the separate durable executor, which is experimental and keeps its own journal.
- No parallel step execution. Execution is sequential and conditional; arbitrary DAG scheduling is not claimed.
- No loops. Cycles are rejected at validation time.
- Synchronous step functions are rejected. A blocking or CPU-bound function would stall the
event loop and could not be cancelled;
await asyncio.to_thread(...)inside an async step instead, accepting that cancellation then cannot stop the thread. - No decorator or imperative workflow API. Definitions stay declarative so they remain
inspectable; see
PLAN.mdAppendix A. - Branches that merge must be mutually exclusive, and at most one edge leaves a step.