| name | duroxide-python-orchestrations |
| description | Writing durable workflows in Python using duroxide-python. Use when creating orchestrations, activities, writing tests, or when the user mentions generator workflows, yield patterns, or duroxide-python development. |
Duroxide-Python Orchestration Development
Core Rule: Yield vs Regular Functions
| Context | Syntax | Why |
|---|
| Orchestrations | def + yield (generator) | Rust replay engine needs step-by-step control |
| Activities | def (regular function) | Run once, result cached — no replay constraints |
| Orchestration tracing | Direct call (no yield) | Fire-and-forget, delegates to Rust |
@runtime.register_orchestration("MyWorkflow")
def my_workflow(ctx, input):
ctx.trace_info("starting")
result = yield ctx.schedule_activity("Work", input)
return result
@runtime.register_activity("Work")
def work(ctx, input):
ctx.trace_info(f"processing {input}")
data = requests.get(input["url"]).json()
return data
Never use async def with yield for orchestrations — async generators break the replay model.
Orchestration Context API
Scheduling (MUST yield)
def my_orch(ctx, input):
result = yield ctx.schedule_activity("Name", input)
result = yield ctx.schedule_activity_with_retry("Name", input, {
"max_attempts": 3,
"backoff": "exponential",
"timeout_ms": 5000,
"total_timeout_ms": 30000,
})
yield ctx.schedule_timer(60000)
event_data = yield ctx.wait_for_event("approval")
child_result = yield ctx.schedule_sub_orchestration("Child", child_input)
child_result = yield ctx.schedule_sub_orchestration_with_id("Child", "child-1", child_input)
yield ctx.start_orchestration("BackgroundWork", "bg-1", bg_input)
now = yield ctx.utc_now()
guid = yield ctx.new_guid()
ctx.continue_as_new(new_input)
Composition (MUST yield)
results = yield ctx.all([
ctx.schedule_activity("TaskA", input_a),
ctx.schedule_activity("TaskB", input_b),
ctx.schedule_timer(5000),
ctx.wait_for_event("approval"),
])
winner = yield ctx.race(
ctx.schedule_activity("FastService", input),
ctx.schedule_timer(5000),
)
ctx.race() supports exactly 2 tasks (maps to Rust select2). Nesting all()/race() inside all() or race() is not supported — the runtime rejects it.
Cooperative Activity Cancellation
@runtime.register_activity("LongTask")
def long_task(ctx, input):
for i in range(1000):
if ctx.is_cancelled():
ctx.trace_info("cancelled, cleaning up")
return {"status": "cancelled"}
time.sleep(0.1)
return {"status": "done"}
ctx.is_cancelled() checks whether the orchestration no longer needs the activity result (e.g., lost a race). Detection latency is worker_lock_timeout_ms / 2 (default 30s → ~15s).
Tracing (NO yield — fire-and-forget)
ctx.trace_info("message")
ctx.trace_warn("message")
ctx.trace_error("message")
ctx.trace_debug("message")
Tracing delegates to the Rust OrchestrationContext which has the is_replaying guard. Do not use print() in orchestrations — it will duplicate on replay.
Activity Context API
@runtime.register_activity("MyActivity")
def my_activity(ctx, input):
ctx.instance_id
ctx.execution_id
ctx.orchestration_name
ctx.orchestration_version
ctx.activity_name
ctx.worker_id
if ctx.is_cancelled():
ctx.trace_info("cancelled")
return {"status": "cancelled"}
ctx.trace_info(f"processing {input['id']}")
ctx.trace_warn("slow response")
ctx.trace_error("connection failed")
ctx.trace_debug("raw payload: ...")
data = requests.get(input["url"]).json()
return data
Determinism Rules
Orchestrations must be deterministic — the replay engine re-executes from the beginning on every dispatch:
| ✅ Safe | ❌ Breaks Replay |
|---|
yield ctx.utc_now() | time.time() |
yield ctx.new_guid() | uuid.uuid4() |
ctx.trace_info() | print() (duplicates) |
yield ctx.schedule_timer(ms) | time.sleep() |
| Pure computation, conditionals | requests.get(), file I/O, DB queries |
json.loads(), json.dumps() | os.environ["X"] (may change) |
All I/O belongs in activities, not orchestrations.
Common Patterns
Error Handling
def my_orch(ctx, input):
try:
result = yield ctx.schedule_activity("RiskyOp", input)
return result
except Exception as e:
ctx.trace_error(f"failed: {e}")
yield ctx.schedule_activity("Cleanup", {"error": str(e)})
return {"status": "failed"}
Eternal Orchestration (continue-as-new)
def monitor(ctx, input):
state = input.get("state", {"iteration": 0})
health = yield ctx.schedule_activity("CheckHealth", input["target"])
ctx.trace_info(f"check #{state['iteration']}: {health['status']}")
yield ctx.schedule_timer(30000)
yield ctx.continue_as_new({
"target": input["target"],
"state": {"iteration": state["iteration"] + 1},
})
Race with Timeout
def my_orch(ctx, input):
winner = yield ctx.race(
ctx.schedule_activity("SlowOperation", input),
ctx.schedule_timer(10000),
)
if winner["index"] == 1:
ctx.trace_warn("operation timed out")
return {"status": "timeout"}
return {"status": "ok", "result": winner["value"]}
Versioned Orchestrations
@runtime.register_orchestration("MyWorkflow")
def my_workflow_v1(ctx, input):
ctx.trace_info("[v1.0.0] starting")
return (yield ctx.schedule_activity("Work", input))
@runtime.register_orchestration_versioned("MyWorkflow", "1.0.1")
def my_workflow_v2(ctx, input):
ctx.trace_info("[v1.0.1] starting")
yield ctx.schedule_activity("Validate", input)
return (yield ctx.schedule_activity("Work", input))
Writing Tests
Tests use pytest:
import time, pytest
from duroxide import SqliteProvider, PostgresProvider, Client, Runtime, PyRuntimeOptions
@pytest.fixture(scope="module")
def provider():
db_url = os.environ.get("DATABASE_URL")
if not db_url:
pytest.skip("DATABASE_URL not set")
return PostgresProvider.connect_with_schema(db_url, "my_test_schema")
def test_my_feature(provider):
client = Client(provider)
runtime = Runtime(provider, PyRuntimeOptions(dispatcher_poll_interval_ms=50))
runtime.register_activity("Echo", lambda ctx, inp: inp)
@runtime.register_orchestration("MyWorkflow")
def my_workflow(ctx, input):
return (yield ctx.schedule_activity("Echo", input))
runtime.start()
try:
client.start_orchestration("test-1", "MyWorkflow", "hello")
result = client.wait_for_orchestration("test-1", 10_000)
assert result.status == "Completed"
assert result.output == "hello"
finally:
runtime.shutdown(100)
Test Commands
source .venv/bin/activate
maturin develop
pytest -v
pytest tests/test_e2e.py -v
pytest tests/test_races.py -v
pytest tests/test_admin_api.py -v
pytest tests/scenarios/ -v
Test Tips
- Use
SqliteProvider.in_memory() for fast isolated tests (SQLite smoketest only)
- All PG tests need
DATABASE_URL in .env (loaded by python-dotenv)
- Each test file uses a separate PG schema for isolation
- Use short
runtime.shutdown(100) timeout — it waits the full duration
- Set
RUST_LOG=info and use pytest -s to see traces in test output
- Use
worker_lock_timeout_ms=2000 in tests needing fast activity cancellation detection
Logging Control
RUST_LOG=info pytest -s
RUST_LOG=duroxide::orchestration=debug pytest -s
RUST_LOG=duroxide::activity=info pytest -s
Traces include structured fields automatically:
- Orchestration:
instance_id, execution_id, orchestration_name, orchestration_version
- Activity: above +
activity_name, activity_id, worker_id