Workflows & Actions
Put work in actions and coordinate it in workflows. This split lets Waymark save progress between calls and retry an individual action when you ask it to.
Two primitives
Actions are the distributed work. Network calls, database queries,
file I/O, anything that can fail and that you'd want to retry independently.
An action is a single async def decorated with @action. Each invocation
is recorded in Postgres, dispatched to a Python worker, runs to completion
or failure, and the result is persisted before the workflow advances.
Workflows are the durable control flow. Loops, conditionals, parallel
branches, try/except. They orchestrate actions but don't do heavy lifting
themselves. A workflow is a class that subclasses Workflow and is
decorated with @workflow. The class body has one method that matters,
run(), and that body is what Waymark compiles into a durable program.
The quickstart's math example uses three actions. Their implementations are omitted here so you can see how the workflow connects them:
import asyncio
from waymark import Workflow, action, workflow
@action
async def compute_factorial(n: int) -> int:
...
@action
async def compute_fibonacci(n: int) -> int:
...
@action
async def summarize(*, n: int, factorial_value: int, fibonacci_value: int) -> str:
...
@workflow
class ParallelMathWorkflow(Workflow):
async def run(self, number: int) -> str:
factorial_value, fibonacci_value = await asyncio.gather(
compute_factorial(number),
compute_fibonacci(number),
return_exceptions=False,
)
return await summarize(
n=number,
factorial_value=factorial_value,
fibonacci_value=fibonacci_value,
)
The runtime saves the workflow's progress between action calls. Workers execute
the three actions. gather propagates failures; return_exceptions=False is
optional. Collecting exceptions with return_exceptions=True is unsupported
and rejected when the workflow is compiled.
Running one looks like calling any other async function:
result = await ParallelMathWorkflow().run(7)
The call queues the workflow into Postgres, the runtime executes it
durably, and you get the result back. If the workflow ends with an
exception it didn't catch, the call raises WorkflowFailedError, whose
exception_value describes that exception. The
Quickstart walks through this end to end, and the
Workflow API covers the call's other options.
Actions
Actions are written like any other async Python function:
@action
async def fetch_user(user_id: str) -> User:
return await db.fetch_one(User, user_id)
A few things to know:
- Inside a workflow, call an action where you await it -
await fetch_user(user_id), or directly insideasyncio.gather(...). Creating the call first and awaiting it later (task = fetch_user(user_id)) doesn't compile. - An action that raises an exception is, by default, marked failed and
not retried. You opt into retries by wrapping the call in
self.run_action(...)and providing aRetryPolicy. See Retries & Timeouts. - Arguments and results travel between processes and are persisted in Postgres, so they need to be values Waymark can serialize: primitives, containers, pydantic models, dataclasses and common stdlib types. See Introduction to Python for the full list, and Known issues for the values that currently change shape on the way.
- Actions can use anything Python lets them:
random(),datetime.now(), HTTP clients, file handles. Anything outside what the workflow compiler accepts belongs in an action.
Workflows
A workflow has one job: orchestrate actions through control flow. The
constraints on the body of run() come from what the compiler can lower
to IR - loops, conditionals, parallel gather, try/except, durable
sleep, returns. Anything outside that vocabulary is rejected the first
time the workflow is run or scheduled.
The runtime persists state as the workflow runs. When another node resumes it,
execution continues from the last saved state without replaying run() from
the beginning. Side effects and I/O belong in actions, outside the compiled
control flow. An action that was in flight during a node failure may run again;
see Production Deployment.
Workflows can run on a cron or a fixed interval with
schedule_workflow, and can pause indefinitely
with await asyncio.sleep(...) - a durable sleep that survives node
restarts.
Dependency injection
Actions can declare dependencies the same way Mountaineer and FastAPI controllers do:
from typing import Annotated
from waymark import Depends, action
@action
async def send_email(
to: str,
subject: str,
emailer: Annotated[EmailClient, Depends(get_email_client)],
) -> None:
await emailer.send(to=to, subject=subject)
The Depends helper is re-exported from mountaineer-di; the older
Depend(...) spelling is kept as an alias. Dependencies are resolved per
action invocation by the worker, so any per-request lifecycle (database
sessions, scoped clients) is handled the same way you'd handle it in a web
handler.
Workflow bodies don't take injected dependencies - they never run in a Python worker. The body is compiled, and the node's runtime executes the compiled program. If a workflow needs a database handle, it asks for it via an action.
How the line gets enforced
Waymark's compiler walks the AST of run() and refuses anything it
doesn't recognize. Common things you might reach for that don't work
inside a workflow body:
f-strings, anywhere in the body (build them inside an action instead).- Calling most functions other than actions and the workflow's own methods; see what does not compile for the exact list.
- Constructing classes other than simple pydantic models and
dataclasses (models without validators or an
__init__of their own, dataclasses without__post_init__, defined in or imported by name into the workflow's module).
The error you get back tells you what wasn't recognized, and one from the SDK's builder also says what to do instead. The Control Flow guide walks through the patterns the compiler supports today, with examples for each, and lists the known issues that still slip past it.