Workflows¶
Pynenc workflows add durable orchestration semantics to distributed tasks. Use them when one business operation spans multiple task invocations and retrying the operation must not duplicate work that already finished.
A workflow is explicit:
@app.workflow
def fulfill_order_workflow(order: dict) -> dict:
fulfill_order_workflow.wf.set_data("status", "started")
tracking_id = fulfill_order_workflow.wf.root.uuid()
...
Ordinary @app.task functions are activities. They can do side effects, read
inputs, call external systems, and update shared workflow data when they are
running inside a workflow. They do not define workflows by themselves.
Use workflows for multi-step processes such as order fulfillment, customer onboarding, document approval, report generation, or any process where retrying the orchestrator should preserve completed child work.
Workflow Tasks And Task Invocations¶
Pynenc separates workflow-defining invocations from ordinary task invocations:
Concept |
Created by |
Meaning |
|---|---|---|
|
|
A task that defines a workflow root or sub-workflow root |
|
|
An ordinary activity task |
|
calling a |
Defines a workflow identity |
|
calling a |
May run standalone or as a child inside a workflow |
Calling an ordinary task at the top level creates a standalone invocation, not a workflow. Calling a workflow task at the top level creates a workflow root. Calling a workflow task from inside another workflow creates a sub-workflow with its own workflow id and a parent workflow id.
Workflow Identity¶
Every workflow-defining invocation owns a WorkflowIdentity:
workflow_id = workflow-defining invocation id
workflow_type = workflow task identifier
parent = parent workflow id, if this is a sub-workflow
That identity is the durable boundary for workflow data, deterministic root operations, and child invocation replay.
@app.workflow
def shipping_workflow(order_id: str) -> dict:
return {
"workflow_id": str(shipping_workflow.wf.identity.workflow_id),
"parent_workflow_id": str(shipping_workflow.wf.identity.parent_workflow_id),
}
Workflow Context¶
task.wf exposes workflow membership when the current invocation is inside a
workflow:
Capability |
API |
Available from |
|---|---|---|
Identity |
|
workflow roots and child tasks inside the workflow |
Durable data |
|
workflow roots and child tasks inside the workflow |
Deterministic values |
|
workflow-defining invocations only |
Durable child calls |
|
workflow-defining invocations only |
The split is intentional. Workflow data is a shared distributed datastore for
the workflow run, so child tasks may read and write it. Orchestration decisions
belong to the workflow root, so deterministic values and child task replay live
under wf.root.
Trying to use workflow APIs from a standalone ordinary task fails clearly. Trying
to call root-only operations from an ordinary child task raises
DeterministicOperationScopeError.
Replay Model¶
If a workflow task is retried, Pynenc runs the Python function again. The workflow root APIs provide the durable pieces needed to make that retry safe:
wf.root.uuid(),wf.root.random(), andwf.root.utc_now()replay values stored for the workflow-defining invocation id.wf.root.execute_task(...)returns a previously recorded child invocation when the same workflow reaches the same child call with the same arguments.wf.get_data(...)andwf.set_data(...)preserve workflow milestones and decisions across attempts.
This means a retry can move past work that already succeeded:
shipment_id = fulfill_order_workflow.wf.root.uuid()
shipment_inv = fulfill_order_workflow.wf.root.execute_task(
create_shipment,
order_id,
shipment_id,
)
shipment = shipment_inv.result
if order.get("simulate_transient_after_shipping") and not fulfill_order_workflow.wf.get_data("probe_raised"):
fulfill_order_workflow.wf.set_data("probe_raised", True)
raise TransientFulfillmentError("fail once after shipment")
On retry, the workflow function runs from the top, but earlier child calls can resolve to already recorded invocations. Only the work after the failure point has to run.
Note
Child replay is keyed by task and arguments. Two calls to the same child task with identical arguments represent the same recorded child invocation. Add a distinct argument, such as a line id or step name, when you need two separate side effects.
Deterministic Operations¶
Workflow root code should not call uuid.uuid4(), random.random(), or
datetime.now() for values that must replay after retry. Use wf.root instead:
token = generate_invoice.wf.root.uuid()
sample = generate_invoice.wf.root.random()
created_at = generate_invoice.wf.root.utc_now()
Those values are stored in workflow state under the workflow id. Because
workflow_id is the invocation id of the workflow-defining task, a retry of the
same workflow invocation replays the same deterministic sequence. A new
workflow invocation gets a new workflow id and therefore a new sequence.
Important
Determinism depends on operation order. If a retry takes a different branch before the same deterministic calls are replayed, it may intentionally generate new values for the new branch. Store business decisions in workflow data when they must remain fixed across branches.
Child Tasks¶
Keep side effects in ordinary tasks and pass deterministic values from the workflow root:
@app.task
def charge_payment(order_id: str, amount: float, payment_id: str) -> dict:
charge_payment.wf.set_data("payment_id", payment_id)
return {"payment_id": payment_id, "approved": True}
@app.workflow
def fulfill_order_workflow(order: dict) -> dict:
payment_id = fulfill_order_workflow.wf.root.uuid()
payment_inv = fulfill_order_workflow.wf.root.execute_task(
charge_payment,
order["order_id"],
order["total"],
payment_id,
)
return payment_inv.result
The child task can update shared workflow data, but it cannot create workflow
root deterministic values or orchestrate other children through wf.root.
Sub-Workflows¶
Use another @app.workflow when a nested part of the process deserves its own
workflow identity and lifecycle:
@app.workflow
def shipping_workflow(order_id: str) -> dict:
shipping_workflow.wf.set_data("status", "shipping_started")
...
@app.workflow
def fulfill_order_workflow(order: dict) -> dict:
shipment_inv = fulfill_order_workflow.wf.root.execute_task(
shipping_workflow,
order["order_id"],
)
return shipment_inv.result
A sub-workflow has its own workflow data and deterministic root sequence. Pass values explicitly when it needs parent context.
Workflow Data¶
Workflow data is a durable key-value store scoped to one workflow identity:
fulfill_order_workflow.wf.set_data("order_id", order_id)
fulfill_order_workflow.wf.set_data("status", "payment_authorized")
status = fulfill_order_workflow.wf.get_data("status", "unknown")
Use workflow data for milestones and decisions that should survive retry:
statusattempt_countreservation_idpayment_idshipment_idfailure_reason
Avoid using workflow data as an unbounded event log. Store large payloads through normal task results or an application data store.
Failure Recovery¶
Workflows usually handle business failures and infrastructure failures differently.
For business failures, return a terminal business status and run compensating tasks explicitly:
if not payment["approved"]:
release_inv = fulfill_order_workflow.wf.root.execute_task(
release_inventory,
order_id,
reservation_id,
"payment_declined",
)
fulfill_order_workflow.wf.set_data("status", "payment_failed")
return {"status": "payment_failed", "release": release_inv.result}
For transient infrastructure failures, raise a retriable exception from a
workflow configured with retry_for and max_retries. The same workflow
invocation is retried, so the workflow identity and workflow data remain
attached.
Pynenc does not currently expose a production pause/resume workflow API. The
WorkflowPauseError type exists internally, but runner support for user-facing
pause/resume is not implemented yet.
Monitoring¶
Pynmon reads the same durable state backend as the worker:
uv run pynenc --app tasks.app monitor
Useful workflow views:
/workflows/lists workflow types and run counts./workflows/runslists every workflow run./workflows/{workflow_type}lists runs for one workflow type./invocations?workflow_id=<workflow-id>filters invocations to one workflow.Invocation detail pages show workflow membership, status history, arguments, result, and family tree.
Workflow-defining invocations are visually outlined in timeline views, so root workflows and sub-workflow roots stand out from ordinary child tasks.
Pynmon’s value for workflows is causal debugging: which workflow run did this invocation belong to, which child task did it call, did the workflow retry, and which child calls were already completed before retry?
Tutorial¶
For a practical order-fulfillment walkthrough, see Use Case 11: Workflow System.
For the runnable sample, see:
samples/workflow_order_fulfillment