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,682 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Workflow execution engine.
|
|
3
|
+
|
|
4
|
+
The executor is responsible for:
|
|
5
|
+
- Starting new workflow runs
|
|
6
|
+
- Resuming existing runs
|
|
7
|
+
- Managing workflow lifecycle
|
|
8
|
+
- Coordinating with storage backend and runtimes
|
|
9
|
+
|
|
10
|
+
Supports multiple runtimes (local, celery) and durability modes (durable, transient).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import uuid
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from loguru import logger
|
|
18
|
+
|
|
19
|
+
from pyworkflow.core.exceptions import (
|
|
20
|
+
ContinueAsNewSignal,
|
|
21
|
+
SuspensionSignal,
|
|
22
|
+
WorkflowAlreadyRunningError,
|
|
23
|
+
WorkflowNotFoundError,
|
|
24
|
+
)
|
|
25
|
+
from pyworkflow.core.registry import get_workflow_by_func
|
|
26
|
+
from pyworkflow.core.workflow import execute_workflow_with_context
|
|
27
|
+
from pyworkflow.engine.events import (
|
|
28
|
+
create_cancellation_requested_event,
|
|
29
|
+
create_workflow_cancelled_event,
|
|
30
|
+
create_workflow_continued_as_new_event,
|
|
31
|
+
)
|
|
32
|
+
from pyworkflow.serialization.encoder import serialize_args, serialize_kwargs
|
|
33
|
+
from pyworkflow.storage.base import StorageBackend
|
|
34
|
+
from pyworkflow.storage.schemas import RunStatus, WorkflowRun
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ConfigurationError(Exception):
|
|
38
|
+
"""Configuration error for PyWorkflow."""
|
|
39
|
+
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def start(
|
|
44
|
+
workflow_func: Callable,
|
|
45
|
+
*args: Any,
|
|
46
|
+
runtime: str | None = None,
|
|
47
|
+
durable: bool | None = None,
|
|
48
|
+
storage: StorageBackend | None = None,
|
|
49
|
+
idempotency_key: str | None = None,
|
|
50
|
+
**kwargs: Any,
|
|
51
|
+
) -> str:
|
|
52
|
+
"""
|
|
53
|
+
Start a new workflow execution.
|
|
54
|
+
|
|
55
|
+
The runtime and durability mode can be specified per-call, or will use
|
|
56
|
+
the configured defaults.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
workflow_func: Workflow function decorated with @workflow
|
|
60
|
+
*args: Positional arguments for workflow
|
|
61
|
+
runtime: Runtime to use ("local", "celery", etc.) or None for default
|
|
62
|
+
durable: Whether workflow is durable (None = use workflow/config default)
|
|
63
|
+
storage: Storage backend instance (None = use configured storage)
|
|
64
|
+
idempotency_key: Optional key for idempotent execution
|
|
65
|
+
**kwargs: Keyword arguments for workflow
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
run_id: Unique identifier for this workflow run
|
|
69
|
+
|
|
70
|
+
Examples:
|
|
71
|
+
# Basic usage (uses configured defaults)
|
|
72
|
+
run_id = await start(my_workflow, 42)
|
|
73
|
+
|
|
74
|
+
# Transient workflow (no persistence)
|
|
75
|
+
run_id = await start(my_workflow, 42, durable=False)
|
|
76
|
+
|
|
77
|
+
# Durable workflow with storage
|
|
78
|
+
run_id = await start(
|
|
79
|
+
my_workflow, 42,
|
|
80
|
+
durable=True,
|
|
81
|
+
storage=InMemoryStorageBackend()
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Explicit local runtime
|
|
85
|
+
run_id = await start(my_workflow, 42, runtime="local")
|
|
86
|
+
|
|
87
|
+
# With idempotency key
|
|
88
|
+
run_id = await start(
|
|
89
|
+
my_workflow, 42,
|
|
90
|
+
idempotency_key="unique-operation-id"
|
|
91
|
+
)
|
|
92
|
+
"""
|
|
93
|
+
from pyworkflow.config import get_config
|
|
94
|
+
from pyworkflow.runtime import get_runtime, validate_runtime_durable
|
|
95
|
+
|
|
96
|
+
config = get_config()
|
|
97
|
+
|
|
98
|
+
# Get workflow metadata
|
|
99
|
+
workflow_meta = get_workflow_by_func(workflow_func)
|
|
100
|
+
if not workflow_meta:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"Function {workflow_func.__name__} is not registered as a workflow. "
|
|
103
|
+
f"Did you forget the @workflow decorator?"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
workflow_name = workflow_meta.name
|
|
107
|
+
|
|
108
|
+
# Resolve runtime
|
|
109
|
+
runtime_name = runtime or config.default_runtime
|
|
110
|
+
runtime_instance = get_runtime(runtime_name)
|
|
111
|
+
|
|
112
|
+
# Resolve durable flag (priority: call arg > decorator > config default)
|
|
113
|
+
workflow_durable = getattr(workflow_func, "__workflow_durable__", None)
|
|
114
|
+
effective_durable = (
|
|
115
|
+
durable
|
|
116
|
+
if durable is not None
|
|
117
|
+
else workflow_durable
|
|
118
|
+
if workflow_durable is not None
|
|
119
|
+
else config.default_durable
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Validate runtime + durable combination
|
|
123
|
+
validate_runtime_durable(runtime_instance, effective_durable)
|
|
124
|
+
|
|
125
|
+
# Resolve storage
|
|
126
|
+
effective_storage = storage or config.storage
|
|
127
|
+
if effective_durable and effective_storage is None:
|
|
128
|
+
raise ConfigurationError(
|
|
129
|
+
"Durable workflows require storage. Either:\n"
|
|
130
|
+
" 1. Pass storage=... to start()\n"
|
|
131
|
+
" 2. Configure globally via pyworkflow.configure(storage=...)\n"
|
|
132
|
+
" 3. Use durable=False for transient workflows"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Check idempotency key (only for durable workflows with storage)
|
|
136
|
+
if idempotency_key and effective_durable and effective_storage:
|
|
137
|
+
existing_run = await effective_storage.get_run_by_idempotency_key(idempotency_key)
|
|
138
|
+
if existing_run:
|
|
139
|
+
if existing_run.status == RunStatus.RUNNING:
|
|
140
|
+
raise WorkflowAlreadyRunningError(existing_run.run_id)
|
|
141
|
+
logger.info(
|
|
142
|
+
f"Workflow with idempotency key '{idempotency_key}' already exists",
|
|
143
|
+
run_id=existing_run.run_id,
|
|
144
|
+
status=existing_run.status.value,
|
|
145
|
+
)
|
|
146
|
+
return existing_run.run_id
|
|
147
|
+
|
|
148
|
+
# Generate run_id
|
|
149
|
+
run_id = f"run_{uuid.uuid4().hex[:16]}"
|
|
150
|
+
|
|
151
|
+
logger.info(
|
|
152
|
+
f"Starting workflow: {workflow_name}",
|
|
153
|
+
run_id=run_id,
|
|
154
|
+
workflow_name=workflow_name,
|
|
155
|
+
runtime=runtime_name,
|
|
156
|
+
durable=effective_durable,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Execute via runtime
|
|
160
|
+
return await runtime_instance.start_workflow(
|
|
161
|
+
workflow_func=workflow_meta.func,
|
|
162
|
+
args=args,
|
|
163
|
+
kwargs=kwargs,
|
|
164
|
+
run_id=run_id,
|
|
165
|
+
workflow_name=workflow_name,
|
|
166
|
+
storage=effective_storage,
|
|
167
|
+
durable=effective_durable,
|
|
168
|
+
idempotency_key=idempotency_key,
|
|
169
|
+
max_duration=workflow_meta.max_duration,
|
|
170
|
+
metadata={}, # Run-level metadata
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def resume(
|
|
175
|
+
run_id: str,
|
|
176
|
+
runtime: str | None = None,
|
|
177
|
+
storage: StorageBackend | None = None,
|
|
178
|
+
) -> Any:
|
|
179
|
+
"""
|
|
180
|
+
Resume a suspended workflow.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
run_id: Workflow run identifier
|
|
184
|
+
runtime: Runtime to use (None = use configured default)
|
|
185
|
+
storage: Storage backend (None = use configured storage)
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Workflow result (if completed) or None (if suspended again)
|
|
189
|
+
|
|
190
|
+
Examples:
|
|
191
|
+
# Resume with configured defaults
|
|
192
|
+
result = await resume("run_abc123")
|
|
193
|
+
|
|
194
|
+
# Resume with explicit storage
|
|
195
|
+
result = await resume("run_abc123", storage=my_storage)
|
|
196
|
+
"""
|
|
197
|
+
from pyworkflow.config import get_config
|
|
198
|
+
from pyworkflow.runtime import get_runtime
|
|
199
|
+
|
|
200
|
+
config = get_config()
|
|
201
|
+
|
|
202
|
+
# Resolve runtime and storage
|
|
203
|
+
runtime_name = runtime or config.default_runtime
|
|
204
|
+
runtime_instance = get_runtime(runtime_name)
|
|
205
|
+
effective_storage = storage or config.storage
|
|
206
|
+
|
|
207
|
+
if effective_storage is None:
|
|
208
|
+
raise ConfigurationError(
|
|
209
|
+
"Cannot resume workflow without storage. "
|
|
210
|
+
"Configure storage via pyworkflow.configure(storage=...) "
|
|
211
|
+
"or pass storage=... to resume()"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
logger.info(
|
|
215
|
+
f"Resuming workflow: {run_id}",
|
|
216
|
+
run_id=run_id,
|
|
217
|
+
runtime=runtime_name,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return await runtime_instance.resume_workflow(
|
|
221
|
+
run_id=run_id,
|
|
222
|
+
storage=effective_storage,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# Internal functions for Celery tasks
|
|
227
|
+
# These execute workflows locally on workers
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
async def _execute_workflow_local(
|
|
231
|
+
workflow_func: Callable,
|
|
232
|
+
run_id: str,
|
|
233
|
+
workflow_name: str,
|
|
234
|
+
storage: StorageBackend,
|
|
235
|
+
args: tuple,
|
|
236
|
+
kwargs: dict,
|
|
237
|
+
event_log: list | None = None,
|
|
238
|
+
) -> Any:
|
|
239
|
+
"""
|
|
240
|
+
Execute workflow locally (used by Celery tasks).
|
|
241
|
+
|
|
242
|
+
This is an internal function called by Celery workers to execute
|
|
243
|
+
workflows. It handles the actual workflow execution with context.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
workflow_func: Workflow function to execute
|
|
247
|
+
run_id: Workflow run ID
|
|
248
|
+
workflow_name: Workflow name
|
|
249
|
+
storage: Storage backend
|
|
250
|
+
args: Workflow arguments
|
|
251
|
+
kwargs: Workflow keyword arguments
|
|
252
|
+
event_log: Optional event log for replay
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Workflow result or None if suspended
|
|
256
|
+
|
|
257
|
+
Raises:
|
|
258
|
+
Exception: On workflow failure
|
|
259
|
+
"""
|
|
260
|
+
try:
|
|
261
|
+
result = await execute_workflow_with_context(
|
|
262
|
+
workflow_func=workflow_func,
|
|
263
|
+
run_id=run_id,
|
|
264
|
+
workflow_name=workflow_name,
|
|
265
|
+
storage=storage,
|
|
266
|
+
args=args,
|
|
267
|
+
kwargs=kwargs,
|
|
268
|
+
event_log=event_log,
|
|
269
|
+
durable=True, # Celery tasks are always durable
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Update run status to completed
|
|
273
|
+
await storage.update_run_status(
|
|
274
|
+
run_id=run_id, status=RunStatus.COMPLETED, result=serialize_args(result)
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
logger.info(
|
|
278
|
+
f"Workflow completed successfully: {workflow_name}",
|
|
279
|
+
run_id=run_id,
|
|
280
|
+
workflow_name=workflow_name,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
except SuspensionSignal as e:
|
|
286
|
+
# Workflow suspended (sleep or hook)
|
|
287
|
+
await storage.update_run_status(run_id=run_id, status=RunStatus.SUSPENDED)
|
|
288
|
+
|
|
289
|
+
logger.info(
|
|
290
|
+
f"Workflow suspended: {e.reason}",
|
|
291
|
+
run_id=run_id,
|
|
292
|
+
workflow_name=workflow_name,
|
|
293
|
+
reason=e.reason,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
except ContinueAsNewSignal as e:
|
|
299
|
+
# Workflow continuing as new execution
|
|
300
|
+
new_run_id = await _handle_continue_as_new(
|
|
301
|
+
current_run_id=run_id,
|
|
302
|
+
workflow_func=workflow_func,
|
|
303
|
+
workflow_name=workflow_name,
|
|
304
|
+
storage=storage,
|
|
305
|
+
new_args=e.workflow_args,
|
|
306
|
+
new_kwargs=e.workflow_kwargs,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
logger.info(
|
|
310
|
+
f"Workflow continued as new: {workflow_name}",
|
|
311
|
+
old_run_id=run_id,
|
|
312
|
+
new_run_id=new_run_id,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
except Exception as e:
|
|
318
|
+
# Workflow failed
|
|
319
|
+
await storage.update_run_status(run_id=run_id, status=RunStatus.FAILED, error=str(e))
|
|
320
|
+
|
|
321
|
+
logger.error(
|
|
322
|
+
f"Workflow failed: {workflow_name}",
|
|
323
|
+
run_id=run_id,
|
|
324
|
+
workflow_name=workflow_name,
|
|
325
|
+
error=str(e),
|
|
326
|
+
exc_info=True,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
raise
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
async def _handle_continue_as_new(
|
|
333
|
+
current_run_id: str,
|
|
334
|
+
workflow_func: Callable,
|
|
335
|
+
workflow_name: str,
|
|
336
|
+
storage: StorageBackend,
|
|
337
|
+
new_args: tuple,
|
|
338
|
+
new_kwargs: dict,
|
|
339
|
+
) -> str:
|
|
340
|
+
"""
|
|
341
|
+
Handle continue-as-new by creating new run and linking it to current.
|
|
342
|
+
|
|
343
|
+
This is an internal function that:
|
|
344
|
+
1. Generates new run_id
|
|
345
|
+
2. Records WORKFLOW_CONTINUED_AS_NEW event in current run
|
|
346
|
+
3. Updates current run status to CONTINUED_AS_NEW
|
|
347
|
+
4. Updates current run's continued_to_run_id
|
|
348
|
+
5. Creates new WorkflowRun with continued_from_run_id
|
|
349
|
+
6. Starts new workflow execution via runtime
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
current_run_id: The run ID of the current workflow
|
|
353
|
+
workflow_func: Workflow function
|
|
354
|
+
workflow_name: Workflow name
|
|
355
|
+
storage: Storage backend
|
|
356
|
+
new_args: Arguments for the new workflow
|
|
357
|
+
new_kwargs: Keyword arguments for the new workflow
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
New run ID
|
|
361
|
+
"""
|
|
362
|
+
from datetime import UTC, datetime
|
|
363
|
+
|
|
364
|
+
from pyworkflow.config import get_config
|
|
365
|
+
from pyworkflow.runtime import get_runtime
|
|
366
|
+
|
|
367
|
+
# Generate new run_id
|
|
368
|
+
new_run_id = f"run_{uuid.uuid4().hex[:16]}"
|
|
369
|
+
|
|
370
|
+
# Serialize arguments
|
|
371
|
+
args_json = serialize_args(*new_args)
|
|
372
|
+
kwargs_json = serialize_kwargs(**new_kwargs)
|
|
373
|
+
|
|
374
|
+
# Record continuation event in current run's log
|
|
375
|
+
continuation_event = create_workflow_continued_as_new_event(
|
|
376
|
+
run_id=current_run_id,
|
|
377
|
+
new_run_id=new_run_id,
|
|
378
|
+
args=args_json,
|
|
379
|
+
kwargs=kwargs_json,
|
|
380
|
+
)
|
|
381
|
+
await storage.record_event(continuation_event)
|
|
382
|
+
|
|
383
|
+
# Update current run status and link to new run
|
|
384
|
+
await storage.update_run_status(
|
|
385
|
+
run_id=current_run_id,
|
|
386
|
+
status=RunStatus.CONTINUED_AS_NEW,
|
|
387
|
+
)
|
|
388
|
+
await storage.update_run_continuation(
|
|
389
|
+
run_id=current_run_id,
|
|
390
|
+
continued_to_run_id=new_run_id,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
# Get current run to copy metadata
|
|
394
|
+
current_run = await storage.get_run(current_run_id)
|
|
395
|
+
nesting_depth = current_run.nesting_depth if current_run else 0
|
|
396
|
+
parent_run_id = current_run.parent_run_id if current_run else None
|
|
397
|
+
|
|
398
|
+
# Create new workflow run linked to current
|
|
399
|
+
new_run = WorkflowRun(
|
|
400
|
+
run_id=new_run_id,
|
|
401
|
+
workflow_name=workflow_name,
|
|
402
|
+
status=RunStatus.PENDING,
|
|
403
|
+
created_at=datetime.now(UTC),
|
|
404
|
+
input_args=args_json,
|
|
405
|
+
input_kwargs=kwargs_json,
|
|
406
|
+
continued_from_run_id=current_run_id,
|
|
407
|
+
nesting_depth=nesting_depth,
|
|
408
|
+
parent_run_id=parent_run_id,
|
|
409
|
+
)
|
|
410
|
+
await storage.create_run(new_run)
|
|
411
|
+
|
|
412
|
+
# Start new workflow via runtime
|
|
413
|
+
config = get_config()
|
|
414
|
+
runtime = get_runtime(config.default_runtime)
|
|
415
|
+
|
|
416
|
+
# Trigger execution of the new run
|
|
417
|
+
await runtime.start_workflow(
|
|
418
|
+
workflow_func=workflow_func,
|
|
419
|
+
args=new_args,
|
|
420
|
+
kwargs=new_kwargs,
|
|
421
|
+
run_id=new_run_id,
|
|
422
|
+
workflow_name=workflow_name,
|
|
423
|
+
storage=storage,
|
|
424
|
+
durable=True,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
return new_run_id
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
async def get_workflow_run(
|
|
431
|
+
run_id: str,
|
|
432
|
+
storage: StorageBackend | None = None,
|
|
433
|
+
) -> WorkflowRun | None:
|
|
434
|
+
"""
|
|
435
|
+
Get workflow run information.
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
run_id: Workflow run identifier
|
|
439
|
+
storage: Storage backend (defaults to configured storage or FileStorageBackend)
|
|
440
|
+
|
|
441
|
+
Returns:
|
|
442
|
+
WorkflowRun if found, None otherwise
|
|
443
|
+
"""
|
|
444
|
+
if storage is None:
|
|
445
|
+
from pyworkflow.config import get_config
|
|
446
|
+
|
|
447
|
+
config = get_config()
|
|
448
|
+
storage = config.storage
|
|
449
|
+
|
|
450
|
+
if storage is None:
|
|
451
|
+
from pyworkflow.storage.file import FileStorageBackend
|
|
452
|
+
|
|
453
|
+
storage = FileStorageBackend()
|
|
454
|
+
|
|
455
|
+
return await storage.get_run(run_id)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
async def get_workflow_events(
|
|
459
|
+
run_id: str,
|
|
460
|
+
storage: StorageBackend | None = None,
|
|
461
|
+
) -> list:
|
|
462
|
+
"""
|
|
463
|
+
Get all events for a workflow run.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
run_id: Workflow run identifier
|
|
467
|
+
storage: Storage backend (defaults to configured storage or FileStorageBackend)
|
|
468
|
+
|
|
469
|
+
Returns:
|
|
470
|
+
List of events ordered by sequence
|
|
471
|
+
"""
|
|
472
|
+
if storage is None:
|
|
473
|
+
from pyworkflow.config import get_config
|
|
474
|
+
|
|
475
|
+
config = get_config()
|
|
476
|
+
storage = config.storage
|
|
477
|
+
|
|
478
|
+
if storage is None:
|
|
479
|
+
from pyworkflow.storage.file import FileStorageBackend
|
|
480
|
+
|
|
481
|
+
storage = FileStorageBackend()
|
|
482
|
+
|
|
483
|
+
return await storage.get_events(run_id)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
async def get_workflow_chain(
|
|
487
|
+
run_id: str,
|
|
488
|
+
storage: StorageBackend | None = None,
|
|
489
|
+
) -> list[WorkflowRun]:
|
|
490
|
+
"""
|
|
491
|
+
Get all workflow runs in a continue-as-new chain.
|
|
492
|
+
|
|
493
|
+
Given any run_id in a chain, returns all runs from the original
|
|
494
|
+
execution to the most recent continuation, ordered from oldest to newest.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
run_id: Any run ID in the chain
|
|
498
|
+
storage: Storage backend (defaults to configured storage or FileStorageBackend)
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
List of WorkflowRun ordered from oldest to newest in the chain
|
|
502
|
+
|
|
503
|
+
Examples:
|
|
504
|
+
# Get full history of a long-running polling workflow
|
|
505
|
+
chain = await get_workflow_chain("run_abc123")
|
|
506
|
+
print(f"Workflow has continued {len(chain) - 1} times")
|
|
507
|
+
for run in chain:
|
|
508
|
+
print(f" {run.run_id}: {run.status.value}")
|
|
509
|
+
"""
|
|
510
|
+
if storage is None:
|
|
511
|
+
from pyworkflow.config import get_config
|
|
512
|
+
|
|
513
|
+
config = get_config()
|
|
514
|
+
storage = config.storage
|
|
515
|
+
|
|
516
|
+
if storage is None:
|
|
517
|
+
from pyworkflow.storage.file import FileStorageBackend
|
|
518
|
+
|
|
519
|
+
storage = FileStorageBackend()
|
|
520
|
+
|
|
521
|
+
return await storage.get_workflow_chain(run_id)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
async def cancel_workflow(
|
|
525
|
+
run_id: str,
|
|
526
|
+
reason: str | None = None,
|
|
527
|
+
wait: bool = False,
|
|
528
|
+
timeout: float | None = None,
|
|
529
|
+
storage: StorageBackend | None = None,
|
|
530
|
+
) -> bool:
|
|
531
|
+
"""
|
|
532
|
+
Request cancellation of a workflow.
|
|
533
|
+
|
|
534
|
+
Cancellation is graceful - running workflows will be cancelled at the next
|
|
535
|
+
interruptible point (before a step, during sleep, etc.). The workflow can
|
|
536
|
+
catch CancellationError to perform cleanup operations.
|
|
537
|
+
|
|
538
|
+
For suspended workflows (sleeping or waiting for hook), the status is
|
|
539
|
+
immediately updated to CANCELLED and a cancellation flag is set for when
|
|
540
|
+
the workflow resumes.
|
|
541
|
+
|
|
542
|
+
For running workflows, a cancellation flag is set that will be detected
|
|
543
|
+
at the next cancellation check point.
|
|
544
|
+
|
|
545
|
+
Note:
|
|
546
|
+
Cancellation does NOT interrupt a step that is already executing.
|
|
547
|
+
If a step takes a long time, cancellation will only be detected after
|
|
548
|
+
the step completes. For long-running steps that need mid-execution
|
|
549
|
+
cancellation, call ``ctx.check_cancellation()`` periodically within
|
|
550
|
+
the step function.
|
|
551
|
+
|
|
552
|
+
Args:
|
|
553
|
+
run_id: Workflow run identifier
|
|
554
|
+
reason: Optional reason for cancellation
|
|
555
|
+
wait: If True, wait for workflow to reach terminal status
|
|
556
|
+
timeout: Maximum seconds to wait (only used if wait=True)
|
|
557
|
+
storage: Storage backend (defaults to configured storage)
|
|
558
|
+
|
|
559
|
+
Returns:
|
|
560
|
+
True if cancellation was initiated, False if workflow is already terminal
|
|
561
|
+
|
|
562
|
+
Raises:
|
|
563
|
+
WorkflowNotFoundError: If workflow run doesn't exist
|
|
564
|
+
TimeoutError: If wait=True and timeout is exceeded
|
|
565
|
+
|
|
566
|
+
Examples:
|
|
567
|
+
# Request cancellation
|
|
568
|
+
cancelled = await cancel_workflow("run_abc123")
|
|
569
|
+
|
|
570
|
+
# Request with reason
|
|
571
|
+
cancelled = await cancel_workflow(
|
|
572
|
+
"run_abc123",
|
|
573
|
+
reason="User requested cancellation"
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
# Wait for cancellation to complete
|
|
577
|
+
cancelled = await cancel_workflow(
|
|
578
|
+
"run_abc123",
|
|
579
|
+
wait=True,
|
|
580
|
+
timeout=30
|
|
581
|
+
)
|
|
582
|
+
"""
|
|
583
|
+
import asyncio
|
|
584
|
+
|
|
585
|
+
# Resolve storage
|
|
586
|
+
if storage is None:
|
|
587
|
+
from pyworkflow.config import get_config
|
|
588
|
+
|
|
589
|
+
config = get_config()
|
|
590
|
+
storage = config.storage
|
|
591
|
+
|
|
592
|
+
if storage is None:
|
|
593
|
+
from pyworkflow.storage.file import FileStorageBackend
|
|
594
|
+
|
|
595
|
+
storage = FileStorageBackend()
|
|
596
|
+
|
|
597
|
+
# Get workflow run
|
|
598
|
+
run = await storage.get_run(run_id)
|
|
599
|
+
if run is None:
|
|
600
|
+
raise WorkflowNotFoundError(run_id)
|
|
601
|
+
|
|
602
|
+
# Check if already in terminal state
|
|
603
|
+
terminal_statuses = {
|
|
604
|
+
RunStatus.COMPLETED,
|
|
605
|
+
RunStatus.FAILED,
|
|
606
|
+
RunStatus.CANCELLED,
|
|
607
|
+
RunStatus.CONTINUED_AS_NEW,
|
|
608
|
+
}
|
|
609
|
+
if run.status in terminal_statuses:
|
|
610
|
+
logger.info(
|
|
611
|
+
f"Workflow already in terminal state: {run.status.value}",
|
|
612
|
+
run_id=run_id,
|
|
613
|
+
status=run.status.value,
|
|
614
|
+
)
|
|
615
|
+
return False
|
|
616
|
+
|
|
617
|
+
# Record cancellation requested event
|
|
618
|
+
cancellation_event = create_cancellation_requested_event(
|
|
619
|
+
run_id=run_id,
|
|
620
|
+
reason=reason,
|
|
621
|
+
requested_by="cancel_workflow",
|
|
622
|
+
)
|
|
623
|
+
await storage.record_event(cancellation_event)
|
|
624
|
+
|
|
625
|
+
logger.info(
|
|
626
|
+
"Cancellation requested for workflow",
|
|
627
|
+
run_id=run_id,
|
|
628
|
+
reason=reason,
|
|
629
|
+
current_status=run.status.value,
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
# Handle based on current status
|
|
633
|
+
if run.status == RunStatus.SUSPENDED:
|
|
634
|
+
# For suspended workflows, update status to CANCELLED immediately
|
|
635
|
+
# The workflow will see cancellation when it tries to resume
|
|
636
|
+
cancelled_event = create_workflow_cancelled_event(
|
|
637
|
+
run_id=run_id,
|
|
638
|
+
reason=reason,
|
|
639
|
+
cleanup_completed=False,
|
|
640
|
+
)
|
|
641
|
+
await storage.record_event(cancelled_event)
|
|
642
|
+
await storage.update_run_status(run_id=run_id, status=RunStatus.CANCELLED)
|
|
643
|
+
|
|
644
|
+
logger.info(
|
|
645
|
+
"Suspended workflow cancelled",
|
|
646
|
+
run_id=run_id,
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
elif run.status in {RunStatus.RUNNING, RunStatus.PENDING}:
|
|
650
|
+
# For running/pending workflows, set cancellation flag
|
|
651
|
+
# The workflow will detect this at the next check point
|
|
652
|
+
await storage.set_cancellation_flag(run_id)
|
|
653
|
+
|
|
654
|
+
logger.info(
|
|
655
|
+
"Cancellation flag set for running workflow",
|
|
656
|
+
run_id=run_id,
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
# Wait for terminal status if requested
|
|
660
|
+
if wait:
|
|
661
|
+
poll_interval = 0.5
|
|
662
|
+
elapsed = 0.0
|
|
663
|
+
effective_timeout = timeout or 60.0
|
|
664
|
+
|
|
665
|
+
while elapsed < effective_timeout:
|
|
666
|
+
run = await storage.get_run(run_id)
|
|
667
|
+
if run and run.status in terminal_statuses:
|
|
668
|
+
logger.info(
|
|
669
|
+
f"Workflow reached terminal state: {run.status.value}",
|
|
670
|
+
run_id=run_id,
|
|
671
|
+
status=run.status.value,
|
|
672
|
+
)
|
|
673
|
+
return True
|
|
674
|
+
|
|
675
|
+
await asyncio.sleep(poll_interval)
|
|
676
|
+
elapsed += poll_interval
|
|
677
|
+
|
|
678
|
+
raise TimeoutError(
|
|
679
|
+
f"Workflow {run_id} did not reach terminal state within {effective_timeout}s"
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
return True
|