pyworkflow-engine 0.1.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dashboard/backend/app/__init__.py +1 -0
- dashboard/backend/app/config.py +32 -0
- dashboard/backend/app/controllers/__init__.py +6 -0
- dashboard/backend/app/controllers/run_controller.py +86 -0
- dashboard/backend/app/controllers/workflow_controller.py +33 -0
- dashboard/backend/app/dependencies/__init__.py +5 -0
- dashboard/backend/app/dependencies/storage.py +50 -0
- dashboard/backend/app/repositories/__init__.py +6 -0
- dashboard/backend/app/repositories/run_repository.py +80 -0
- dashboard/backend/app/repositories/workflow_repository.py +27 -0
- dashboard/backend/app/rest/__init__.py +8 -0
- dashboard/backend/app/rest/v1/__init__.py +12 -0
- dashboard/backend/app/rest/v1/health.py +33 -0
- dashboard/backend/app/rest/v1/runs.py +133 -0
- dashboard/backend/app/rest/v1/workflows.py +41 -0
- dashboard/backend/app/schemas/__init__.py +23 -0
- dashboard/backend/app/schemas/common.py +16 -0
- dashboard/backend/app/schemas/event.py +24 -0
- dashboard/backend/app/schemas/hook.py +25 -0
- dashboard/backend/app/schemas/run.py +54 -0
- dashboard/backend/app/schemas/step.py +28 -0
- dashboard/backend/app/schemas/workflow.py +31 -0
- dashboard/backend/app/server.py +87 -0
- dashboard/backend/app/services/__init__.py +6 -0
- dashboard/backend/app/services/run_service.py +240 -0
- dashboard/backend/app/services/workflow_service.py +155 -0
- dashboard/backend/main.py +18 -0
- docs/concepts/cancellation.mdx +362 -0
- docs/concepts/continue-as-new.mdx +434 -0
- docs/concepts/events.mdx +266 -0
- docs/concepts/fault-tolerance.mdx +370 -0
- docs/concepts/hooks.mdx +552 -0
- docs/concepts/limitations.mdx +167 -0
- docs/concepts/schedules.mdx +775 -0
- docs/concepts/sleep.mdx +312 -0
- docs/concepts/steps.mdx +301 -0
- docs/concepts/workflows.mdx +255 -0
- docs/guides/cli.mdx +942 -0
- docs/guides/configuration.mdx +560 -0
- docs/introduction.mdx +155 -0
- docs/quickstart.mdx +279 -0
- examples/__init__.py +1 -0
- examples/celery/__init__.py +1 -0
- examples/celery/durable/docker-compose.yml +55 -0
- examples/celery/durable/pyworkflow.config.yaml +12 -0
- examples/celery/durable/workflows/__init__.py +122 -0
- examples/celery/durable/workflows/basic.py +87 -0
- examples/celery/durable/workflows/batch_processing.py +102 -0
- examples/celery/durable/workflows/cancellation.py +273 -0
- examples/celery/durable/workflows/child_workflow_patterns.py +240 -0
- examples/celery/durable/workflows/child_workflows.py +202 -0
- examples/celery/durable/workflows/continue_as_new.py +260 -0
- examples/celery/durable/workflows/fault_tolerance.py +210 -0
- examples/celery/durable/workflows/hooks.py +211 -0
- examples/celery/durable/workflows/idempotency.py +112 -0
- examples/celery/durable/workflows/long_running.py +99 -0
- examples/celery/durable/workflows/retries.py +101 -0
- examples/celery/durable/workflows/schedules.py +209 -0
- examples/celery/transient/01_basic_workflow.py +91 -0
- examples/celery/transient/02_fault_tolerance.py +257 -0
- examples/celery/transient/__init__.py +20 -0
- examples/celery/transient/pyworkflow.config.yaml +25 -0
- examples/local/__init__.py +1 -0
- examples/local/durable/01_basic_workflow.py +94 -0
- examples/local/durable/02_file_storage.py +132 -0
- examples/local/durable/03_retries.py +169 -0
- examples/local/durable/04_long_running.py +119 -0
- examples/local/durable/05_event_log.py +145 -0
- examples/local/durable/06_idempotency.py +148 -0
- examples/local/durable/07_hooks.py +334 -0
- examples/local/durable/08_cancellation.py +233 -0
- examples/local/durable/09_child_workflows.py +198 -0
- examples/local/durable/10_child_workflow_patterns.py +265 -0
- examples/local/durable/11_continue_as_new.py +249 -0
- examples/local/durable/12_schedules.py +198 -0
- examples/local/durable/__init__.py +1 -0
- examples/local/transient/01_quick_tasks.py +87 -0
- examples/local/transient/02_retries.py +130 -0
- examples/local/transient/03_sleep.py +141 -0
- examples/local/transient/__init__.py +1 -0
- pyworkflow/__init__.py +256 -0
- pyworkflow/aws/__init__.py +68 -0
- pyworkflow/aws/context.py +234 -0
- pyworkflow/aws/handler.py +184 -0
- pyworkflow/aws/testing.py +310 -0
- pyworkflow/celery/__init__.py +41 -0
- pyworkflow/celery/app.py +198 -0
- pyworkflow/celery/scheduler.py +315 -0
- pyworkflow/celery/tasks.py +1746 -0
- pyworkflow/cli/__init__.py +132 -0
- pyworkflow/cli/__main__.py +6 -0
- pyworkflow/cli/commands/__init__.py +1 -0
- pyworkflow/cli/commands/hooks.py +640 -0
- pyworkflow/cli/commands/quickstart.py +495 -0
- pyworkflow/cli/commands/runs.py +773 -0
- pyworkflow/cli/commands/scheduler.py +130 -0
- pyworkflow/cli/commands/schedules.py +794 -0
- pyworkflow/cli/commands/setup.py +703 -0
- pyworkflow/cli/commands/worker.py +413 -0
- pyworkflow/cli/commands/workflows.py +1257 -0
- pyworkflow/cli/output/__init__.py +1 -0
- pyworkflow/cli/output/formatters.py +321 -0
- pyworkflow/cli/output/styles.py +121 -0
- pyworkflow/cli/utils/__init__.py +1 -0
- pyworkflow/cli/utils/async_helpers.py +30 -0
- pyworkflow/cli/utils/config.py +130 -0
- pyworkflow/cli/utils/config_generator.py +344 -0
- pyworkflow/cli/utils/discovery.py +53 -0
- pyworkflow/cli/utils/docker_manager.py +651 -0
- pyworkflow/cli/utils/interactive.py +364 -0
- pyworkflow/cli/utils/storage.py +115 -0
- pyworkflow/config.py +329 -0
- pyworkflow/context/__init__.py +63 -0
- pyworkflow/context/aws.py +230 -0
- pyworkflow/context/base.py +416 -0
- pyworkflow/context/local.py +930 -0
- pyworkflow/context/mock.py +381 -0
- pyworkflow/core/__init__.py +0 -0
- pyworkflow/core/exceptions.py +353 -0
- pyworkflow/core/registry.py +313 -0
- pyworkflow/core/scheduled.py +328 -0
- pyworkflow/core/step.py +494 -0
- pyworkflow/core/workflow.py +294 -0
- pyworkflow/discovery.py +248 -0
- pyworkflow/engine/__init__.py +0 -0
- pyworkflow/engine/events.py +879 -0
- pyworkflow/engine/executor.py +682 -0
- pyworkflow/engine/replay.py +273 -0
- pyworkflow/observability/__init__.py +19 -0
- pyworkflow/observability/logging.py +234 -0
- pyworkflow/primitives/__init__.py +33 -0
- pyworkflow/primitives/child_handle.py +174 -0
- pyworkflow/primitives/child_workflow.py +372 -0
- pyworkflow/primitives/continue_as_new.py +101 -0
- pyworkflow/primitives/define_hook.py +150 -0
- pyworkflow/primitives/hooks.py +97 -0
- pyworkflow/primitives/resume_hook.py +210 -0
- pyworkflow/primitives/schedule.py +545 -0
- pyworkflow/primitives/shield.py +96 -0
- pyworkflow/primitives/sleep.py +100 -0
- pyworkflow/runtime/__init__.py +21 -0
- pyworkflow/runtime/base.py +179 -0
- pyworkflow/runtime/celery.py +310 -0
- pyworkflow/runtime/factory.py +101 -0
- pyworkflow/runtime/local.py +706 -0
- pyworkflow/scheduler/__init__.py +9 -0
- pyworkflow/scheduler/local.py +248 -0
- pyworkflow/serialization/__init__.py +0 -0
- pyworkflow/serialization/decoder.py +146 -0
- pyworkflow/serialization/encoder.py +162 -0
- pyworkflow/storage/__init__.py +54 -0
- pyworkflow/storage/base.py +612 -0
- pyworkflow/storage/config.py +185 -0
- pyworkflow/storage/dynamodb.py +1315 -0
- pyworkflow/storage/file.py +827 -0
- pyworkflow/storage/memory.py +549 -0
- pyworkflow/storage/postgres.py +1161 -0
- pyworkflow/storage/schemas.py +486 -0
- pyworkflow/storage/sqlite.py +1136 -0
- pyworkflow/utils/__init__.py +0 -0
- pyworkflow/utils/duration.py +177 -0
- pyworkflow/utils/schedule.py +391 -0
- pyworkflow_engine-0.1.7.dist-info/METADATA +687 -0
- pyworkflow_engine-0.1.7.dist-info/RECORD +196 -0
- pyworkflow_engine-0.1.7.dist-info/WHEEL +5 -0
- pyworkflow_engine-0.1.7.dist-info/entry_points.txt +2 -0
- pyworkflow_engine-0.1.7.dist-info/licenses/LICENSE +21 -0
- pyworkflow_engine-0.1.7.dist-info/top_level.txt +5 -0
- tests/examples/__init__.py +0 -0
- tests/integration/__init__.py +0 -0
- tests/integration/test_cancellation.py +330 -0
- tests/integration/test_child_workflows.py +439 -0
- tests/integration/test_continue_as_new.py +428 -0
- tests/integration/test_dynamodb_storage.py +1146 -0
- tests/integration/test_fault_tolerance.py +369 -0
- tests/integration/test_schedule_storage.py +484 -0
- tests/unit/__init__.py +0 -0
- tests/unit/backends/__init__.py +1 -0
- tests/unit/backends/test_dynamodb_storage.py +1554 -0
- tests/unit/backends/test_postgres_storage.py +1281 -0
- tests/unit/backends/test_sqlite_storage.py +1460 -0
- tests/unit/conftest.py +41 -0
- tests/unit/test_cancellation.py +364 -0
- tests/unit/test_child_workflows.py +680 -0
- tests/unit/test_continue_as_new.py +441 -0
- tests/unit/test_event_limits.py +316 -0
- tests/unit/test_executor.py +320 -0
- tests/unit/test_fault_tolerance.py +334 -0
- tests/unit/test_hooks.py +495 -0
- tests/unit/test_registry.py +261 -0
- tests/unit/test_replay.py +420 -0
- tests/unit/test_schedule_schemas.py +285 -0
- tests/unit/test_schedule_utils.py +286 -0
- tests/unit/test_scheduled_workflow.py +274 -0
- tests/unit/test_step.py +353 -0
- tests/unit/test_workflow.py +243 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Durable Workflow - Cancellation
|
|
3
|
+
|
|
4
|
+
This example demonstrates graceful workflow cancellation:
|
|
5
|
+
- Cancel running or suspended workflows
|
|
6
|
+
- Handle CancellationError for cleanup
|
|
7
|
+
- Use shield() to protect critical operations
|
|
8
|
+
- Checkpoint-based cancellation (not mid-step)
|
|
9
|
+
|
|
10
|
+
Run: python examples/local/durable/08_cancellation.py 2>/dev/null
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import tempfile
|
|
15
|
+
|
|
16
|
+
from pyworkflow import (
|
|
17
|
+
CancellationError,
|
|
18
|
+
cancel_workflow,
|
|
19
|
+
configure,
|
|
20
|
+
get_workflow_run,
|
|
21
|
+
reset_config,
|
|
22
|
+
shield,
|
|
23
|
+
sleep,
|
|
24
|
+
start,
|
|
25
|
+
step,
|
|
26
|
+
workflow,
|
|
27
|
+
)
|
|
28
|
+
from pyworkflow.storage import FileStorageBackend
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# --- Steps ---
|
|
32
|
+
@step()
|
|
33
|
+
async def reserve_inventory(order_id: str) -> dict:
|
|
34
|
+
"""Reserve inventory for order."""
|
|
35
|
+
print(f" [Step] Reserving inventory for order {order_id}...")
|
|
36
|
+
await asyncio.sleep(0.1) # Simulate work
|
|
37
|
+
return {"order_id": order_id, "inventory_reserved": True}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@step()
|
|
41
|
+
async def charge_payment(order: dict) -> dict:
|
|
42
|
+
"""Charge payment for order."""
|
|
43
|
+
print(f" [Step] Charging payment for order {order['order_id']}...")
|
|
44
|
+
await asyncio.sleep(0.1) # Simulate work
|
|
45
|
+
return {**order, "payment_charged": True}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@step()
|
|
49
|
+
async def create_shipment(order: dict) -> dict:
|
|
50
|
+
"""Create shipment for order."""
|
|
51
|
+
print(f" [Step] Creating shipment for order {order['order_id']}...")
|
|
52
|
+
await asyncio.sleep(0.1) # Simulate work
|
|
53
|
+
return {**order, "shipment_created": True}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@step()
|
|
57
|
+
async def release_inventory(order_id: str) -> None:
|
|
58
|
+
"""Release previously reserved inventory (compensation)."""
|
|
59
|
+
print(f" [Cleanup] Releasing inventory for order {order_id}...")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@step()
|
|
63
|
+
async def refund_payment(order_id: str) -> None:
|
|
64
|
+
"""Refund charged payment (compensation)."""
|
|
65
|
+
print(f" [Cleanup] Refunding payment for order {order_id}...")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# --- Workflow with Cancellation Handling ---
|
|
69
|
+
@workflow(durable=True, tags=["local", "durable"])
|
|
70
|
+
async def order_workflow(order_id: str) -> dict:
|
|
71
|
+
"""
|
|
72
|
+
Order processing workflow with cancellation handling.
|
|
73
|
+
|
|
74
|
+
If cancelled:
|
|
75
|
+
- Catches CancellationError
|
|
76
|
+
- Uses shield() to ensure cleanup completes
|
|
77
|
+
- Releases inventory and refunds payment
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
order = await reserve_inventory(order_id)
|
|
81
|
+
|
|
82
|
+
# Simulate waiting for approval
|
|
83
|
+
print(" [Workflow] Waiting 5s for approval (can be cancelled here)...")
|
|
84
|
+
await sleep("5s")
|
|
85
|
+
|
|
86
|
+
order = await charge_payment(order)
|
|
87
|
+
|
|
88
|
+
# Another wait - e.g., for warehouse processing
|
|
89
|
+
print(" [Workflow] Waiting 5s for warehouse (can be cancelled here)...")
|
|
90
|
+
await sleep("5s")
|
|
91
|
+
|
|
92
|
+
order = await create_shipment(order)
|
|
93
|
+
return order
|
|
94
|
+
|
|
95
|
+
except CancellationError as e:
|
|
96
|
+
print(f"\n [Workflow] Cancellation detected! Reason: {e.reason}")
|
|
97
|
+
print(" [Workflow] Performing cleanup...")
|
|
98
|
+
|
|
99
|
+
# Use shield() to ensure cleanup completes even if cancelled again
|
|
100
|
+
async with shield():
|
|
101
|
+
await release_inventory(order_id)
|
|
102
|
+
await refund_payment(order_id)
|
|
103
|
+
|
|
104
|
+
print(" [Workflow] Cleanup complete, re-raising CancellationError")
|
|
105
|
+
raise # Re-raise to mark workflow as cancelled
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def example_cancel_suspended_workflow(storage):
|
|
109
|
+
"""Example 1: Cancel a workflow while it's suspended (sleeping)."""
|
|
110
|
+
print("\n--- Example 1: Cancel Suspended Workflow ---\n")
|
|
111
|
+
|
|
112
|
+
# Start workflow
|
|
113
|
+
run_id = await start(order_workflow, "order-001")
|
|
114
|
+
|
|
115
|
+
# Check status - should be suspended (sleeping)
|
|
116
|
+
run = await get_workflow_run(run_id)
|
|
117
|
+
print(f"Workflow status: {run.status.value}")
|
|
118
|
+
|
|
119
|
+
if run.status.value == "suspended":
|
|
120
|
+
print("\nWorkflow is suspended (sleeping). Cancelling...")
|
|
121
|
+
|
|
122
|
+
# Cancel the suspended workflow
|
|
123
|
+
cancelled = await cancel_workflow(
|
|
124
|
+
run_id,
|
|
125
|
+
reason="Customer cancelled order",
|
|
126
|
+
storage=storage,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
print(f"Cancellation initiated: {cancelled}")
|
|
130
|
+
|
|
131
|
+
# Check final status
|
|
132
|
+
run = await get_workflow_run(run_id)
|
|
133
|
+
print(f"Final status: {run.status.value}")
|
|
134
|
+
|
|
135
|
+
return run_id
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def example_cancel_running_workflow(storage):
|
|
139
|
+
"""Example 2: Cancel a running workflow (cancellation at next checkpoint)."""
|
|
140
|
+
print("\n--- Example 2: Cancel Running Workflow ---\n")
|
|
141
|
+
|
|
142
|
+
# Define a workflow that we can cancel mid-execution
|
|
143
|
+
@workflow(durable=True)
|
|
144
|
+
async def multi_step_workflow(job_id: str) -> dict:
|
|
145
|
+
try:
|
|
146
|
+
print(f" [Step 1] Starting job {job_id}...")
|
|
147
|
+
await asyncio.sleep(0.1)
|
|
148
|
+
|
|
149
|
+
print(" [Step 2] Processing (cancellation checked here)...")
|
|
150
|
+
# Note: In real scenario, ctx.check_cancellation() would be called
|
|
151
|
+
# by @step decorator before execution
|
|
152
|
+
await asyncio.sleep(0.1)
|
|
153
|
+
|
|
154
|
+
print(" [Step 3] Finalizing...")
|
|
155
|
+
return {"job_id": job_id, "status": "done"}
|
|
156
|
+
|
|
157
|
+
except CancellationError as e:
|
|
158
|
+
print(f" [Workflow] Cancelled! Reason: {e.reason}")
|
|
159
|
+
raise
|
|
160
|
+
|
|
161
|
+
# Start and immediately cancel (before it completes)
|
|
162
|
+
run_id = await start(multi_step_workflow, "job-002")
|
|
163
|
+
|
|
164
|
+
run = await get_workflow_run(run_id)
|
|
165
|
+
print(f"Workflow status: {run.status.value}")
|
|
166
|
+
|
|
167
|
+
# If it completed before we could cancel, that's OK
|
|
168
|
+
if run.status.value != "completed":
|
|
169
|
+
print("\nCancelling workflow...")
|
|
170
|
+
cancelled = await cancel_workflow(
|
|
171
|
+
run_id,
|
|
172
|
+
reason="Test cancellation",
|
|
173
|
+
storage=storage,
|
|
174
|
+
)
|
|
175
|
+
print(f"Cancellation initiated: {cancelled}")
|
|
176
|
+
|
|
177
|
+
# Check final status
|
|
178
|
+
run = await get_workflow_run(run_id)
|
|
179
|
+
print(f"Final status: {run.status.value}")
|
|
180
|
+
|
|
181
|
+
return run_id
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def example_cancel_already_completed(storage):
|
|
185
|
+
"""Example 3: Try to cancel an already completed workflow."""
|
|
186
|
+
print("\n--- Example 3: Cancel Completed Workflow ---\n")
|
|
187
|
+
|
|
188
|
+
# Create a simple quick workflow
|
|
189
|
+
@workflow(durable=True)
|
|
190
|
+
async def quick_workflow() -> str:
|
|
191
|
+
return "done"
|
|
192
|
+
|
|
193
|
+
# Start and complete
|
|
194
|
+
run_id = await start(quick_workflow)
|
|
195
|
+
run = await get_workflow_run(run_id)
|
|
196
|
+
print(f"Workflow status: {run.status.value}")
|
|
197
|
+
|
|
198
|
+
# Try to cancel
|
|
199
|
+
print("Attempting to cancel completed workflow...")
|
|
200
|
+
cancelled = await cancel_workflow(run_id, storage=storage)
|
|
201
|
+
|
|
202
|
+
print(f"Cancellation result: {cancelled}")
|
|
203
|
+
print("(False means workflow was already in terminal state)")
|
|
204
|
+
|
|
205
|
+
return run_id
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
async def main():
|
|
209
|
+
# Use temp directory
|
|
210
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
211
|
+
print("=== Durable Workflow - Cancellation ===")
|
|
212
|
+
|
|
213
|
+
# Configure with FileStorageBackend
|
|
214
|
+
reset_config()
|
|
215
|
+
storage = FileStorageBackend(base_path=tmpdir)
|
|
216
|
+
configure(storage=storage, default_durable=True)
|
|
217
|
+
|
|
218
|
+
# Run examples
|
|
219
|
+
await example_cancel_suspended_workflow(storage)
|
|
220
|
+
await example_cancel_running_workflow(storage)
|
|
221
|
+
await example_cancel_already_completed(storage)
|
|
222
|
+
|
|
223
|
+
print("\n=== Key Takeaways ===")
|
|
224
|
+
print(" - cancel_workflow() requests graceful cancellation")
|
|
225
|
+
print(" - Suspended workflows are cancelled immediately")
|
|
226
|
+
print(" - Running workflows cancel at next checkpoint (before step/sleep/hook)")
|
|
227
|
+
print(" - Catch CancellationError for cleanup logic")
|
|
228
|
+
print(" - Use shield() to protect critical cleanup operations")
|
|
229
|
+
print(" - Cancellation does NOT interrupt a step mid-execution")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == "__main__":
|
|
233
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Child Workflows - Basic Example
|
|
3
|
+
|
|
4
|
+
This example demonstrates how to spawn child workflows from a parent workflow.
|
|
5
|
+
- Parent workflow spawns child workflows using start_child_workflow()
|
|
6
|
+
- Children have their own run_id and event history
|
|
7
|
+
- wait_for_completion=True (default) waits for child to complete
|
|
8
|
+
- wait_for_completion=False returns a ChildWorkflowHandle immediately
|
|
9
|
+
- TERMINATE policy: when parent completes/fails/cancels, children are cancelled
|
|
10
|
+
|
|
11
|
+
Run: python examples/local/durable/09_child_workflows.py 2>/dev/null
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
|
|
16
|
+
from pyworkflow import (
|
|
17
|
+
ChildWorkflowHandle,
|
|
18
|
+
configure,
|
|
19
|
+
get_workflow_run,
|
|
20
|
+
reset_config,
|
|
21
|
+
start,
|
|
22
|
+
start_child_workflow,
|
|
23
|
+
step,
|
|
24
|
+
workflow,
|
|
25
|
+
)
|
|
26
|
+
from pyworkflow.storage import InMemoryStorageBackend
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# --- Steps ---
|
|
30
|
+
@step()
|
|
31
|
+
async def validate_order(order_id: str) -> dict:
|
|
32
|
+
"""Validate order details."""
|
|
33
|
+
print(f" Validating order {order_id}...")
|
|
34
|
+
return {"order_id": order_id, "valid": True}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@step()
|
|
38
|
+
async def process_payment(order_id: str, amount: float) -> dict:
|
|
39
|
+
"""Process payment for order."""
|
|
40
|
+
print(f" Processing payment ${amount:.2f} for {order_id}...")
|
|
41
|
+
return {"order_id": order_id, "amount": amount, "paid": True}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@step()
|
|
45
|
+
async def ship_order(order_id: str) -> dict:
|
|
46
|
+
"""Ship the order."""
|
|
47
|
+
print(f" Shipping order {order_id}...")
|
|
48
|
+
return {"order_id": order_id, "shipped": True}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@step()
|
|
52
|
+
async def send_email(recipient: str, subject: str) -> dict:
|
|
53
|
+
"""Send an email notification."""
|
|
54
|
+
print(f" Sending email to {recipient}: {subject}")
|
|
55
|
+
return {"recipient": recipient, "subject": subject, "sent": True}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --- Child Workflows ---
|
|
59
|
+
@workflow(durable=True, tags=["local", "durable"])
|
|
60
|
+
async def payment_workflow(order_id: str, amount: float) -> dict:
|
|
61
|
+
"""Child workflow for payment processing."""
|
|
62
|
+
print(f" [PaymentWorkflow] Starting for order {order_id}")
|
|
63
|
+
result = await process_payment(order_id, amount)
|
|
64
|
+
print(f" [PaymentWorkflow] Completed for order {order_id}")
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@workflow(durable=True, tags=["local", "durable"])
|
|
69
|
+
async def shipping_workflow(order_id: str) -> dict:
|
|
70
|
+
"""Child workflow for shipping."""
|
|
71
|
+
print(f" [ShippingWorkflow] Starting for order {order_id}")
|
|
72
|
+
result = await ship_order(order_id)
|
|
73
|
+
print(f" [ShippingWorkflow] Completed for order {order_id}")
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@workflow(durable=True, tags=["local", "durable"])
|
|
78
|
+
async def notification_workflow(email: str, order_id: str) -> dict:
|
|
79
|
+
"""Child workflow for sending notifications."""
|
|
80
|
+
print(f" [NotificationWorkflow] Starting for {email}")
|
|
81
|
+
result = await send_email(email, f"Order {order_id} update")
|
|
82
|
+
print(f" [NotificationWorkflow] Completed for {email}")
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# --- Parent Workflow ---
|
|
87
|
+
@workflow(durable=True, tags=["local", "durable"])
|
|
88
|
+
async def order_fulfillment_workflow(
|
|
89
|
+
order_id: str,
|
|
90
|
+
amount: float,
|
|
91
|
+
customer_email: str,
|
|
92
|
+
) -> dict:
|
|
93
|
+
"""
|
|
94
|
+
Parent workflow that orchestrates order fulfillment using child workflows.
|
|
95
|
+
|
|
96
|
+
This demonstrates:
|
|
97
|
+
1. wait_for_completion=True - Wait for child to complete (default)
|
|
98
|
+
2. wait_for_completion=False - Fire-and-forget with handle
|
|
99
|
+
"""
|
|
100
|
+
print(f"[OrderFulfillment] Starting for order {order_id}")
|
|
101
|
+
|
|
102
|
+
# Step 1: Validate order (regular step)
|
|
103
|
+
validation = await validate_order(order_id)
|
|
104
|
+
if not validation["valid"]:
|
|
105
|
+
return {"order_id": order_id, "status": "invalid"}
|
|
106
|
+
|
|
107
|
+
# Step 2: Process payment via child workflow (wait for completion)
|
|
108
|
+
print("[OrderFulfillment] Starting payment child workflow...")
|
|
109
|
+
payment_result = await start_child_workflow(
|
|
110
|
+
payment_workflow,
|
|
111
|
+
order_id,
|
|
112
|
+
amount,
|
|
113
|
+
wait_for_completion=True, # Default: wait for child to complete
|
|
114
|
+
)
|
|
115
|
+
print(f"[OrderFulfillment] Payment completed: {payment_result}")
|
|
116
|
+
|
|
117
|
+
# Step 3: Ship order via child workflow (wait for completion)
|
|
118
|
+
print("[OrderFulfillment] Starting shipping child workflow...")
|
|
119
|
+
shipping_result = await start_child_workflow(
|
|
120
|
+
shipping_workflow,
|
|
121
|
+
order_id,
|
|
122
|
+
wait_for_completion=True,
|
|
123
|
+
)
|
|
124
|
+
print(f"[OrderFulfillment] Shipping completed: {shipping_result}")
|
|
125
|
+
|
|
126
|
+
# Step 4: Send notification via fire-and-forget child workflow
|
|
127
|
+
# This returns immediately with a handle, parent continues
|
|
128
|
+
print("[OrderFulfillment] Starting notification child workflow (fire-and-forget)...")
|
|
129
|
+
notification_handle: ChildWorkflowHandle = await start_child_workflow(
|
|
130
|
+
notification_workflow,
|
|
131
|
+
customer_email,
|
|
132
|
+
order_id,
|
|
133
|
+
wait_for_completion=False, # Fire-and-forget
|
|
134
|
+
)
|
|
135
|
+
print(f"[OrderFulfillment] Notification child started: {notification_handle.child_run_id}")
|
|
136
|
+
|
|
137
|
+
# We can optionally check status or wait for the handle later
|
|
138
|
+
# For now, we'll just let it run in the background
|
|
139
|
+
|
|
140
|
+
result = {
|
|
141
|
+
"order_id": order_id,
|
|
142
|
+
"status": "fulfilled",
|
|
143
|
+
"payment": payment_result,
|
|
144
|
+
"shipping": shipping_result,
|
|
145
|
+
"notification_run_id": notification_handle.child_run_id,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
print(f"[OrderFulfillment] Completed for order {order_id}")
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def main():
|
|
153
|
+
# Configure with InMemoryStorageBackend
|
|
154
|
+
reset_config()
|
|
155
|
+
storage = InMemoryStorageBackend()
|
|
156
|
+
configure(storage=storage, default_durable=True)
|
|
157
|
+
|
|
158
|
+
print("=== Child Workflows - Basic Example ===\n")
|
|
159
|
+
print("Running order fulfillment workflow with child workflows...\n")
|
|
160
|
+
|
|
161
|
+
# Start parent workflow
|
|
162
|
+
run_id = await start(
|
|
163
|
+
order_fulfillment_workflow,
|
|
164
|
+
"order-456",
|
|
165
|
+
149.99,
|
|
166
|
+
"customer@example.com",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Give fire-and-forget child time to complete
|
|
170
|
+
await asyncio.sleep(0.5)
|
|
171
|
+
|
|
172
|
+
print("\n=== Workflow Results ===")
|
|
173
|
+
|
|
174
|
+
# Check parent workflow
|
|
175
|
+
parent_run = await get_workflow_run(run_id)
|
|
176
|
+
print(f"\nParent Workflow: {run_id}")
|
|
177
|
+
print(f" Status: {parent_run.status.value}")
|
|
178
|
+
print(f" Result: {parent_run.result}")
|
|
179
|
+
|
|
180
|
+
# List child workflows
|
|
181
|
+
children = await storage.get_children(run_id)
|
|
182
|
+
print(f"\nChild Workflows ({len(children)} total):")
|
|
183
|
+
for child in children:
|
|
184
|
+
print(f" - {child.run_id}")
|
|
185
|
+
print(f" Workflow: {child.workflow_name}")
|
|
186
|
+
print(f" Status: {child.status.value}")
|
|
187
|
+
print(f" Nesting Depth: {child.nesting_depth}")
|
|
188
|
+
|
|
189
|
+
print("\n=== Key Takeaways ===")
|
|
190
|
+
print("1. start_child_workflow() spawns a child with its own run_id")
|
|
191
|
+
print("2. wait_for_completion=True (default) waits for child result")
|
|
192
|
+
print("3. wait_for_completion=False returns a ChildWorkflowHandle")
|
|
193
|
+
print("4. Child workflows have their own event history")
|
|
194
|
+
print("5. TERMINATE policy: children cancelled when parent completes")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
if __name__ == "__main__":
|
|
198
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Child Workflows - Advanced Patterns
|
|
3
|
+
|
|
4
|
+
This example demonstrates advanced child workflow patterns:
|
|
5
|
+
- Nested child workflows (parent -> child -> grandchild)
|
|
6
|
+
- Parallel child workflows using asyncio.gather
|
|
7
|
+
- Error handling with ChildWorkflowFailedError
|
|
8
|
+
- Cancellation propagation (TERMINATE policy)
|
|
9
|
+
- Using ChildWorkflowHandle for async patterns
|
|
10
|
+
|
|
11
|
+
Run: python examples/local/durable/10_child_workflow_patterns.py 2>/dev/null
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
|
|
16
|
+
from pyworkflow import (
|
|
17
|
+
ChildWorkflowFailedError,
|
|
18
|
+
MaxNestingDepthError,
|
|
19
|
+
configure,
|
|
20
|
+
get_workflow_run,
|
|
21
|
+
reset_config,
|
|
22
|
+
start,
|
|
23
|
+
start_child_workflow,
|
|
24
|
+
step,
|
|
25
|
+
workflow,
|
|
26
|
+
)
|
|
27
|
+
from pyworkflow.storage import InMemoryStorageBackend
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# --- Steps ---
|
|
31
|
+
@step()
|
|
32
|
+
async def do_work(name: str, duration: float = 0.1) -> dict:
|
|
33
|
+
"""Simulate some work."""
|
|
34
|
+
print(f" [{name}] Working for {duration}s...")
|
|
35
|
+
await asyncio.sleep(duration)
|
|
36
|
+
return {"name": name, "completed": True}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@step()
|
|
40
|
+
async def failing_step() -> dict:
|
|
41
|
+
"""A step that always fails."""
|
|
42
|
+
raise ValueError("This step always fails!")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# --- Workflows for Nesting Demo ---
|
|
46
|
+
@workflow(durable=True, tags=["local", "durable"])
|
|
47
|
+
async def level_3_workflow(task_id: str) -> dict:
|
|
48
|
+
"""Grandchild workflow (nesting depth 3)."""
|
|
49
|
+
print(f" [Level3] Starting task {task_id}")
|
|
50
|
+
result = await do_work(f"level3-{task_id}")
|
|
51
|
+
print(f" [Level3] Completed task {task_id}")
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@workflow(durable=True, tags=["local", "durable"])
|
|
56
|
+
async def level_2_workflow(task_id: str) -> dict:
|
|
57
|
+
"""Child workflow that spawns a grandchild."""
|
|
58
|
+
print(f" [Level2] Starting task {task_id}")
|
|
59
|
+
|
|
60
|
+
# Spawn grandchild (nesting depth 3)
|
|
61
|
+
grandchild_result = await start_child_workflow(
|
|
62
|
+
level_3_workflow,
|
|
63
|
+
task_id,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
print(f" [Level2] Completed task {task_id}")
|
|
67
|
+
return {"level2": task_id, "grandchild": grandchild_result}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@workflow(durable=True, tags=["local", "durable"])
|
|
71
|
+
async def level_1_workflow() -> dict:
|
|
72
|
+
"""Parent workflow demonstrating max nesting depth."""
|
|
73
|
+
print("[Level1] Starting nested workflow demo")
|
|
74
|
+
|
|
75
|
+
# Spawn child which will spawn grandchild
|
|
76
|
+
child_result = await start_child_workflow(level_2_workflow, "nested-task")
|
|
77
|
+
|
|
78
|
+
print("[Level1] Completed nested workflow demo")
|
|
79
|
+
return {"level1": True, "child": child_result}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --- Workflows for Parallel Demo ---
|
|
83
|
+
@workflow(durable=True, tags=["local", "durable"])
|
|
84
|
+
async def parallel_task_workflow(task_id: str, duration: float) -> dict:
|
|
85
|
+
"""A simple workflow for parallel execution."""
|
|
86
|
+
result = await do_work(f"parallel-{task_id}", duration)
|
|
87
|
+
return {"task_id": task_id, **result}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@workflow(durable=True, tags=["local", "durable"])
|
|
91
|
+
async def parallel_parent_workflow() -> dict:
|
|
92
|
+
"""Parent workflow that runs multiple children in parallel."""
|
|
93
|
+
print("[ParallelParent] Starting parallel children demo")
|
|
94
|
+
|
|
95
|
+
# Start multiple children with fire-and-forget
|
|
96
|
+
handles = []
|
|
97
|
+
for i in range(3):
|
|
98
|
+
handle = await start_child_workflow(
|
|
99
|
+
parallel_task_workflow,
|
|
100
|
+
f"task-{i}",
|
|
101
|
+
0.1 * (i + 1), # Different durations
|
|
102
|
+
wait_for_completion=False,
|
|
103
|
+
)
|
|
104
|
+
handles.append(handle)
|
|
105
|
+
print(f" Started child {i}: {handle.child_run_id}")
|
|
106
|
+
|
|
107
|
+
# Wait for all children to complete using handles
|
|
108
|
+
print("[ParallelParent] Waiting for all children...")
|
|
109
|
+
results = []
|
|
110
|
+
for i, handle in enumerate(handles):
|
|
111
|
+
result = await handle.result(timeout=10.0)
|
|
112
|
+
results.append(result)
|
|
113
|
+
print(f" Child {i} completed: {result}")
|
|
114
|
+
|
|
115
|
+
print("[ParallelParent] All children completed")
|
|
116
|
+
return {"children_count": len(results), "results": results}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# --- Workflows for Error Handling Demo ---
|
|
120
|
+
@workflow(durable=True, tags=["local", "durable"])
|
|
121
|
+
async def failing_child_workflow() -> dict:
|
|
122
|
+
"""A child workflow that fails."""
|
|
123
|
+
await failing_step()
|
|
124
|
+
return {"should": "never reach here"}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@workflow(durable=True, tags=["local", "durable"])
|
|
128
|
+
async def error_handling_parent_workflow() -> dict:
|
|
129
|
+
"""Parent workflow demonstrating error handling."""
|
|
130
|
+
print("[ErrorParent] Starting error handling demo")
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
# This child will fail
|
|
134
|
+
await start_child_workflow(failing_child_workflow)
|
|
135
|
+
except ChildWorkflowFailedError as e:
|
|
136
|
+
print("[ErrorParent] Caught child failure!")
|
|
137
|
+
print(f" Child run_id: {e.child_run_id}")
|
|
138
|
+
print(f" Child workflow: {e.child_workflow_name}")
|
|
139
|
+
print(f" Error: {e.error}")
|
|
140
|
+
print(f" Error type: {e.error_type}")
|
|
141
|
+
return {
|
|
142
|
+
"status": "child_failed",
|
|
143
|
+
"error": e.error,
|
|
144
|
+
"child_run_id": e.child_run_id,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {"status": "success"}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def demo_nesting():
|
|
151
|
+
"""Demo: Nested child workflows."""
|
|
152
|
+
print("\n" + "=" * 50)
|
|
153
|
+
print("DEMO 1: Nested Child Workflows (3 levels)")
|
|
154
|
+
print("=" * 50 + "\n")
|
|
155
|
+
|
|
156
|
+
run_id = await start(level_1_workflow)
|
|
157
|
+
run = await get_workflow_run(run_id)
|
|
158
|
+
|
|
159
|
+
print(f"\nResult: {run.result}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def demo_parallel():
|
|
163
|
+
"""Demo: Parallel child workflows."""
|
|
164
|
+
print("\n" + "=" * 50)
|
|
165
|
+
print("DEMO 2: Parallel Child Workflows")
|
|
166
|
+
print("=" * 50 + "\n")
|
|
167
|
+
|
|
168
|
+
run_id = await start(parallel_parent_workflow)
|
|
169
|
+
run = await get_workflow_run(run_id)
|
|
170
|
+
|
|
171
|
+
print(f"\nResult: {run.result}")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def demo_error_handling():
|
|
175
|
+
"""Demo: Error handling with ChildWorkflowFailedError."""
|
|
176
|
+
print("\n" + "=" * 50)
|
|
177
|
+
print("DEMO 3: Child Workflow Error Handling")
|
|
178
|
+
print("=" * 50 + "\n")
|
|
179
|
+
|
|
180
|
+
run_id = await start(error_handling_parent_workflow)
|
|
181
|
+
run = await get_workflow_run(run_id)
|
|
182
|
+
|
|
183
|
+
print(f"\nResult: {run.result}")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def demo_max_nesting_depth():
|
|
187
|
+
"""Demo: Max nesting depth enforcement."""
|
|
188
|
+
print("\n" + "=" * 50)
|
|
189
|
+
print("DEMO 4: Max Nesting Depth (3 levels max)")
|
|
190
|
+
print("=" * 50 + "\n")
|
|
191
|
+
|
|
192
|
+
# Define a workflow that tries to nest too deep
|
|
193
|
+
@workflow(durable=True)
|
|
194
|
+
async def level_4_workflow() -> dict:
|
|
195
|
+
"""This would be nesting depth 4 - not allowed!"""
|
|
196
|
+
return await do_work("level4")
|
|
197
|
+
|
|
198
|
+
@workflow(durable=True)
|
|
199
|
+
async def try_level_4_workflow() -> dict:
|
|
200
|
+
"""Try to spawn a level 4 child (should fail)."""
|
|
201
|
+
# This is called from level 3, so it would be depth 4
|
|
202
|
+
try:
|
|
203
|
+
await start_child_workflow(level_4_workflow)
|
|
204
|
+
except MaxNestingDepthError as e:
|
|
205
|
+
print(f" Caught MaxNestingDepthError: {e}")
|
|
206
|
+
return {"error": str(e), "max_depth": e.MAX_DEPTH}
|
|
207
|
+
return {"status": "success"}
|
|
208
|
+
|
|
209
|
+
@workflow(durable=True)
|
|
210
|
+
async def deep_nesting_workflow() -> dict:
|
|
211
|
+
"""Workflow that attempts deep nesting."""
|
|
212
|
+
print("[DeepNesting] Attempting 4-level nesting...")
|
|
213
|
+
|
|
214
|
+
# Level 1 -> Level 2
|
|
215
|
+
@workflow(durable=True)
|
|
216
|
+
async def level_2() -> dict:
|
|
217
|
+
print(" [Level2] Starting...")
|
|
218
|
+
|
|
219
|
+
# Level 2 -> Level 3
|
|
220
|
+
@workflow(durable=True)
|
|
221
|
+
async def level_3() -> dict:
|
|
222
|
+
print(" [Level3] Starting...")
|
|
223
|
+
# Level 3 -> Level 4 (should fail!)
|
|
224
|
+
return await start_child_workflow(try_level_4_workflow)
|
|
225
|
+
|
|
226
|
+
return await start_child_workflow(level_3)
|
|
227
|
+
|
|
228
|
+
return await start_child_workflow(level_2)
|
|
229
|
+
|
|
230
|
+
run_id = await start(deep_nesting_workflow)
|
|
231
|
+
await asyncio.sleep(0.5) # Wait for nested workflows
|
|
232
|
+
run = await get_workflow_run(run_id)
|
|
233
|
+
|
|
234
|
+
print(f"\nResult: {run.result}")
|
|
235
|
+
print(
|
|
236
|
+
"\nNote: Max nesting depth is 3 levels (root=0, child=1, grandchild=2, great-grandchild=3)"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
async def main():
|
|
241
|
+
# Configure with InMemoryStorageBackend
|
|
242
|
+
reset_config()
|
|
243
|
+
storage = InMemoryStorageBackend()
|
|
244
|
+
configure(storage=storage, default_durable=True)
|
|
245
|
+
|
|
246
|
+
print("=== Child Workflows - Advanced Patterns ===")
|
|
247
|
+
|
|
248
|
+
await demo_nesting()
|
|
249
|
+
await demo_parallel()
|
|
250
|
+
await demo_error_handling()
|
|
251
|
+
# Skip max nesting demo as it requires proper registration
|
|
252
|
+
# await demo_max_nesting_depth()
|
|
253
|
+
|
|
254
|
+
print("\n" + "=" * 50)
|
|
255
|
+
print("=== Key Takeaways ===")
|
|
256
|
+
print("=" * 50)
|
|
257
|
+
print("1. Child workflows can spawn their own children (up to depth 3)")
|
|
258
|
+
print("2. Use wait_for_completion=False + handle.result() for parallel")
|
|
259
|
+
print("3. ChildWorkflowFailedError propagates child failures to parent")
|
|
260
|
+
print("4. MaxNestingDepthError prevents infinite nesting")
|
|
261
|
+
print("5. TERMINATE policy ensures cleanup on parent completion")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
if __name__ == "__main__":
|
|
265
|
+
asyncio.run(main())
|