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,706 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local runtime - executes workflows in-process.
|
|
3
|
+
|
|
4
|
+
The local runtime is ideal for:
|
|
5
|
+
- CI/CD pipelines
|
|
6
|
+
- Local development
|
|
7
|
+
- Testing
|
|
8
|
+
- Simple scripts that don't need distributed execution
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
14
|
+
|
|
15
|
+
from loguru import logger
|
|
16
|
+
|
|
17
|
+
from pyworkflow.core.exceptions import (
|
|
18
|
+
CancellationError,
|
|
19
|
+
ContinueAsNewSignal,
|
|
20
|
+
SuspensionSignal,
|
|
21
|
+
WorkflowNotFoundError,
|
|
22
|
+
)
|
|
23
|
+
from pyworkflow.runtime.base import Runtime
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from pyworkflow.storage.base import StorageBackend
|
|
27
|
+
from pyworkflow.storage.schemas import RunStatus
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def _handle_parent_completion_local(
|
|
31
|
+
run_id: str,
|
|
32
|
+
status: "RunStatus",
|
|
33
|
+
storage: "StorageBackend",
|
|
34
|
+
) -> None:
|
|
35
|
+
"""
|
|
36
|
+
Handle parent workflow completion by cancelling all running children.
|
|
37
|
+
|
|
38
|
+
When a parent workflow reaches a terminal state (COMPLETED, FAILED, CANCELLED),
|
|
39
|
+
all running child workflows are automatically cancelled. This implements the
|
|
40
|
+
TERMINATE parent close policy.
|
|
41
|
+
"""
|
|
42
|
+
from pyworkflow.engine.events import EventType, create_child_workflow_cancelled_event
|
|
43
|
+
from pyworkflow.engine.executor import cancel_workflow
|
|
44
|
+
from pyworkflow.storage.schemas import RunStatus
|
|
45
|
+
|
|
46
|
+
# Get all non-terminal children
|
|
47
|
+
children = await storage.get_children(run_id)
|
|
48
|
+
non_terminal_statuses = {
|
|
49
|
+
RunStatus.PENDING,
|
|
50
|
+
RunStatus.RUNNING,
|
|
51
|
+
RunStatus.SUSPENDED,
|
|
52
|
+
RunStatus.INTERRUPTED,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
running_children = [c for c in children if c.status in non_terminal_statuses]
|
|
56
|
+
|
|
57
|
+
if not running_children:
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
logger.info(
|
|
61
|
+
f"Cancelling {len(running_children)} child workflow(s) due to parent {status.value}",
|
|
62
|
+
parent_run_id=run_id,
|
|
63
|
+
parent_status=status.value,
|
|
64
|
+
child_count=len(running_children),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
for child in running_children:
|
|
68
|
+
try:
|
|
69
|
+
reason = f"Parent workflow {run_id} {status.value}"
|
|
70
|
+
|
|
71
|
+
await cancel_workflow(
|
|
72
|
+
run_id=child.run_id,
|
|
73
|
+
reason=reason,
|
|
74
|
+
storage=storage,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Find child_id from parent's events
|
|
78
|
+
events = await storage.get_events(run_id)
|
|
79
|
+
child_id = None
|
|
80
|
+
for event in events:
|
|
81
|
+
if (
|
|
82
|
+
event.type == EventType.CHILD_WORKFLOW_STARTED
|
|
83
|
+
and event.data.get("child_run_id") == child.run_id
|
|
84
|
+
):
|
|
85
|
+
child_id = event.data.get("child_id")
|
|
86
|
+
break
|
|
87
|
+
|
|
88
|
+
if child_id:
|
|
89
|
+
cancel_event = create_child_workflow_cancelled_event(
|
|
90
|
+
run_id=run_id,
|
|
91
|
+
child_id=child_id,
|
|
92
|
+
child_run_id=child.run_id,
|
|
93
|
+
reason=reason,
|
|
94
|
+
)
|
|
95
|
+
await storage.record_event(cancel_event)
|
|
96
|
+
|
|
97
|
+
logger.info(
|
|
98
|
+
f"Cancelled child workflow: {child.workflow_name}",
|
|
99
|
+
parent_run_id=run_id,
|
|
100
|
+
child_run_id=child.run_id,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
except Exception as e:
|
|
104
|
+
logger.error(
|
|
105
|
+
f"Failed to cancel child workflow: {child.workflow_name}",
|
|
106
|
+
parent_run_id=run_id,
|
|
107
|
+
child_run_id=child.run_id,
|
|
108
|
+
error=str(e),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class LocalRuntime(Runtime):
|
|
113
|
+
"""
|
|
114
|
+
Execute workflows directly in the current process.
|
|
115
|
+
|
|
116
|
+
This runtime supports both durable and transient workflows:
|
|
117
|
+
- Durable: Events are recorded, workflows can be resumed
|
|
118
|
+
- Transient: No persistence, simple execution
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def name(self) -> str:
|
|
123
|
+
return "local"
|
|
124
|
+
|
|
125
|
+
async def start_workflow(
|
|
126
|
+
self,
|
|
127
|
+
workflow_func: Callable[..., Any],
|
|
128
|
+
args: tuple,
|
|
129
|
+
kwargs: dict,
|
|
130
|
+
run_id: str,
|
|
131
|
+
workflow_name: str,
|
|
132
|
+
storage: Optional["StorageBackend"],
|
|
133
|
+
durable: bool,
|
|
134
|
+
idempotency_key: str | None = None,
|
|
135
|
+
max_duration: str | None = None,
|
|
136
|
+
metadata: dict | None = None,
|
|
137
|
+
) -> str:
|
|
138
|
+
"""Start a workflow execution in the current process."""
|
|
139
|
+
from pyworkflow.core.workflow import execute_workflow_with_context
|
|
140
|
+
from pyworkflow.engine.events import create_workflow_started_event
|
|
141
|
+
from pyworkflow.serialization.encoder import serialize_args, serialize_kwargs
|
|
142
|
+
from pyworkflow.storage.schemas import RunStatus, WorkflowRun
|
|
143
|
+
|
|
144
|
+
logger.info(
|
|
145
|
+
f"Starting workflow locally: {workflow_name}",
|
|
146
|
+
run_id=run_id,
|
|
147
|
+
workflow_name=workflow_name,
|
|
148
|
+
durable=durable,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if durable and storage is not None:
|
|
152
|
+
# Check if run already exists (e.g., from continue_as_new)
|
|
153
|
+
existing_run = await storage.get_run(run_id)
|
|
154
|
+
if existing_run:
|
|
155
|
+
# Run was pre-created (e.g., by _handle_continue_as_new)
|
|
156
|
+
# Just update status to RUNNING
|
|
157
|
+
await storage.update_run_status(run_id=run_id, status=RunStatus.RUNNING)
|
|
158
|
+
else:
|
|
159
|
+
# Create workflow run record
|
|
160
|
+
workflow_run = WorkflowRun(
|
|
161
|
+
run_id=run_id,
|
|
162
|
+
workflow_name=workflow_name,
|
|
163
|
+
status=RunStatus.RUNNING,
|
|
164
|
+
created_at=datetime.now(UTC),
|
|
165
|
+
started_at=datetime.now(UTC),
|
|
166
|
+
input_args=serialize_args(*args),
|
|
167
|
+
input_kwargs=serialize_kwargs(**kwargs),
|
|
168
|
+
idempotency_key=idempotency_key,
|
|
169
|
+
max_duration=max_duration,
|
|
170
|
+
metadata=metadata or {},
|
|
171
|
+
)
|
|
172
|
+
await storage.create_run(workflow_run)
|
|
173
|
+
|
|
174
|
+
# Record start event
|
|
175
|
+
event = create_workflow_started_event(
|
|
176
|
+
run_id=run_id,
|
|
177
|
+
workflow_name=workflow_name,
|
|
178
|
+
args=serialize_args(*args),
|
|
179
|
+
kwargs=serialize_kwargs(**kwargs),
|
|
180
|
+
)
|
|
181
|
+
await storage.record_event(event)
|
|
182
|
+
|
|
183
|
+
# Execute workflow
|
|
184
|
+
try:
|
|
185
|
+
result = await execute_workflow_with_context(
|
|
186
|
+
workflow_func=workflow_func,
|
|
187
|
+
run_id=run_id,
|
|
188
|
+
workflow_name=workflow_name,
|
|
189
|
+
storage=storage if durable else None,
|
|
190
|
+
args=args,
|
|
191
|
+
kwargs=kwargs,
|
|
192
|
+
durable=durable,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if durable and storage is not None:
|
|
196
|
+
# Update run status to completed
|
|
197
|
+
await storage.update_run_status(
|
|
198
|
+
run_id=run_id,
|
|
199
|
+
status=RunStatus.COMPLETED,
|
|
200
|
+
result=serialize_args(result),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Cancel all running children (TERMINATE policy)
|
|
204
|
+
await _handle_parent_completion_local(run_id, RunStatus.COMPLETED, storage)
|
|
205
|
+
|
|
206
|
+
logger.info(
|
|
207
|
+
f"Workflow completed: {workflow_name}",
|
|
208
|
+
run_id=run_id,
|
|
209
|
+
workflow_name=workflow_name,
|
|
210
|
+
durable=durable,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
return run_id
|
|
214
|
+
|
|
215
|
+
except CancellationError as e:
|
|
216
|
+
if durable and storage is not None:
|
|
217
|
+
from pyworkflow.engine.events import create_workflow_cancelled_event
|
|
218
|
+
|
|
219
|
+
cancelled_event = create_workflow_cancelled_event(
|
|
220
|
+
run_id=run_id,
|
|
221
|
+
reason=e.reason,
|
|
222
|
+
cleanup_completed=True,
|
|
223
|
+
)
|
|
224
|
+
await storage.record_event(cancelled_event)
|
|
225
|
+
await storage.update_run_status(run_id=run_id, status=RunStatus.CANCELLED)
|
|
226
|
+
await storage.clear_cancellation_flag(run_id)
|
|
227
|
+
|
|
228
|
+
# Cancel all running children (TERMINATE policy)
|
|
229
|
+
await _handle_parent_completion_local(run_id, RunStatus.CANCELLED, storage)
|
|
230
|
+
|
|
231
|
+
logger.info(
|
|
232
|
+
f"Workflow cancelled: {workflow_name}",
|
|
233
|
+
run_id=run_id,
|
|
234
|
+
workflow_name=workflow_name,
|
|
235
|
+
reason=e.reason,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
return run_id
|
|
239
|
+
|
|
240
|
+
except SuspensionSignal as e:
|
|
241
|
+
if durable and storage is not None:
|
|
242
|
+
# Workflow suspended (sleep, hook, or retry)
|
|
243
|
+
await storage.update_run_status(run_id=run_id, status=RunStatus.SUSPENDED)
|
|
244
|
+
|
|
245
|
+
# Enhanced logging for retry suspensions
|
|
246
|
+
if e.reason.startswith("retry:"):
|
|
247
|
+
step_id = e.data.get("step_id") if e.data else "unknown"
|
|
248
|
+
attempt = e.data.get("attempt") if e.data else "?"
|
|
249
|
+
resume_at = e.data.get("resume_at") if e.data else "unknown"
|
|
250
|
+
logger.info(
|
|
251
|
+
"Workflow suspended for step retry",
|
|
252
|
+
run_id=run_id,
|
|
253
|
+
workflow_name=workflow_name,
|
|
254
|
+
step_id=step_id,
|
|
255
|
+
next_attempt=attempt,
|
|
256
|
+
resume_at=resume_at,
|
|
257
|
+
)
|
|
258
|
+
else:
|
|
259
|
+
logger.info(
|
|
260
|
+
f"Workflow suspended: {e.reason}",
|
|
261
|
+
run_id=run_id,
|
|
262
|
+
workflow_name=workflow_name,
|
|
263
|
+
reason=e.reason,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
return run_id
|
|
267
|
+
|
|
268
|
+
except ContinueAsNewSignal as e:
|
|
269
|
+
# Workflow continuing as new execution
|
|
270
|
+
if durable and storage is not None:
|
|
271
|
+
from pyworkflow.engine.executor import _handle_continue_as_new
|
|
272
|
+
from pyworkflow.storage.schemas import RunStatus as RS
|
|
273
|
+
|
|
274
|
+
# Cancel all running children (TERMINATE policy)
|
|
275
|
+
await _handle_parent_completion_local(run_id, RS.CONTINUED_AS_NEW, storage)
|
|
276
|
+
|
|
277
|
+
# Handle the continuation
|
|
278
|
+
new_run_id = await _handle_continue_as_new(
|
|
279
|
+
current_run_id=run_id,
|
|
280
|
+
workflow_func=workflow_func,
|
|
281
|
+
workflow_name=workflow_name,
|
|
282
|
+
storage=storage,
|
|
283
|
+
new_args=e.workflow_args,
|
|
284
|
+
new_kwargs=e.workflow_kwargs,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
logger.info(
|
|
288
|
+
f"Workflow continued as new: {workflow_name}",
|
|
289
|
+
run_id=run_id,
|
|
290
|
+
workflow_name=workflow_name,
|
|
291
|
+
new_run_id=new_run_id,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
return run_id
|
|
295
|
+
|
|
296
|
+
except Exception as e:
|
|
297
|
+
if durable and storage is not None:
|
|
298
|
+
# Workflow failed
|
|
299
|
+
await storage.update_run_status(
|
|
300
|
+
run_id=run_id, status=RunStatus.FAILED, error=str(e)
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# Cancel all running children (TERMINATE policy)
|
|
304
|
+
await _handle_parent_completion_local(run_id, RunStatus.FAILED, storage)
|
|
305
|
+
|
|
306
|
+
logger.error(
|
|
307
|
+
f"Workflow failed: {workflow_name}",
|
|
308
|
+
run_id=run_id,
|
|
309
|
+
workflow_name=workflow_name,
|
|
310
|
+
error=str(e),
|
|
311
|
+
exc_info=True,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
raise
|
|
315
|
+
|
|
316
|
+
async def resume_workflow(
|
|
317
|
+
self,
|
|
318
|
+
run_id: str,
|
|
319
|
+
storage: "StorageBackend",
|
|
320
|
+
) -> Any:
|
|
321
|
+
"""Resume a suspended workflow."""
|
|
322
|
+
from pyworkflow.core.registry import get_workflow
|
|
323
|
+
from pyworkflow.core.workflow import execute_workflow_with_context
|
|
324
|
+
from pyworkflow.serialization.decoder import deserialize_args, deserialize_kwargs
|
|
325
|
+
from pyworkflow.serialization.encoder import serialize_args
|
|
326
|
+
from pyworkflow.storage.schemas import RunStatus
|
|
327
|
+
|
|
328
|
+
# Load workflow run
|
|
329
|
+
run = await storage.get_run(run_id)
|
|
330
|
+
if not run:
|
|
331
|
+
raise WorkflowNotFoundError(run_id)
|
|
332
|
+
|
|
333
|
+
logger.info(
|
|
334
|
+
f"Resuming workflow locally: {run.workflow_name}",
|
|
335
|
+
run_id=run_id,
|
|
336
|
+
workflow_name=run.workflow_name,
|
|
337
|
+
current_status=run.status.value,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
# Get workflow function
|
|
341
|
+
workflow_meta = get_workflow(run.workflow_name)
|
|
342
|
+
if not workflow_meta:
|
|
343
|
+
raise ValueError(f"Workflow '{run.workflow_name}' not registered")
|
|
344
|
+
|
|
345
|
+
# Load event log
|
|
346
|
+
events = await storage.get_events(run_id)
|
|
347
|
+
|
|
348
|
+
# Deserialize arguments
|
|
349
|
+
args = deserialize_args(run.input_args)
|
|
350
|
+
kwargs = deserialize_kwargs(run.input_kwargs)
|
|
351
|
+
|
|
352
|
+
# Update status to running
|
|
353
|
+
await storage.update_run_status(run_id=run_id, status=RunStatus.RUNNING)
|
|
354
|
+
|
|
355
|
+
# Execute workflow with event replay
|
|
356
|
+
try:
|
|
357
|
+
result = await execute_workflow_with_context(
|
|
358
|
+
workflow_func=workflow_meta.func,
|
|
359
|
+
run_id=run_id,
|
|
360
|
+
workflow_name=run.workflow_name,
|
|
361
|
+
storage=storage,
|
|
362
|
+
args=args,
|
|
363
|
+
kwargs=kwargs,
|
|
364
|
+
event_log=events,
|
|
365
|
+
durable=True, # Resume is always durable
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
# Update run status to completed
|
|
369
|
+
await storage.update_run_status(
|
|
370
|
+
run_id=run_id,
|
|
371
|
+
status=RunStatus.COMPLETED,
|
|
372
|
+
result=serialize_args(result),
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# Cancel all running children (TERMINATE policy)
|
|
376
|
+
await _handle_parent_completion_local(run_id, RunStatus.COMPLETED, storage)
|
|
377
|
+
|
|
378
|
+
logger.info(
|
|
379
|
+
f"Workflow resumed and completed: {run.workflow_name}",
|
|
380
|
+
run_id=run_id,
|
|
381
|
+
workflow_name=run.workflow_name,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
return result
|
|
385
|
+
|
|
386
|
+
except CancellationError as e:
|
|
387
|
+
from pyworkflow.engine.events import create_workflow_cancelled_event
|
|
388
|
+
|
|
389
|
+
cancelled_event = create_workflow_cancelled_event(
|
|
390
|
+
run_id=run_id,
|
|
391
|
+
reason=e.reason,
|
|
392
|
+
cleanup_completed=True,
|
|
393
|
+
)
|
|
394
|
+
await storage.record_event(cancelled_event)
|
|
395
|
+
await storage.update_run_status(run_id=run_id, status=RunStatus.CANCELLED)
|
|
396
|
+
await storage.clear_cancellation_flag(run_id)
|
|
397
|
+
|
|
398
|
+
# Cancel all running children (TERMINATE policy)
|
|
399
|
+
await _handle_parent_completion_local(run_id, RunStatus.CANCELLED, storage)
|
|
400
|
+
|
|
401
|
+
logger.info(
|
|
402
|
+
f"Workflow cancelled on resume: {run.workflow_name}",
|
|
403
|
+
run_id=run_id,
|
|
404
|
+
workflow_name=run.workflow_name,
|
|
405
|
+
reason=e.reason,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
except SuspensionSignal as e:
|
|
411
|
+
# Workflow suspended again
|
|
412
|
+
await storage.update_run_status(run_id=run_id, status=RunStatus.SUSPENDED)
|
|
413
|
+
|
|
414
|
+
logger.info(
|
|
415
|
+
f"Workflow suspended again: {e.reason}",
|
|
416
|
+
run_id=run_id,
|
|
417
|
+
workflow_name=run.workflow_name,
|
|
418
|
+
reason=e.reason,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
return None
|
|
422
|
+
|
|
423
|
+
except ContinueAsNewSignal as e:
|
|
424
|
+
# Workflow continuing as new execution
|
|
425
|
+
from pyworkflow.engine.executor import _handle_continue_as_new
|
|
426
|
+
|
|
427
|
+
# Cancel all running children (TERMINATE policy)
|
|
428
|
+
await _handle_parent_completion_local(run_id, RunStatus.CONTINUED_AS_NEW, storage)
|
|
429
|
+
|
|
430
|
+
# Handle the continuation
|
|
431
|
+
new_run_id = await _handle_continue_as_new(
|
|
432
|
+
current_run_id=run_id,
|
|
433
|
+
workflow_func=workflow_meta.func,
|
|
434
|
+
workflow_name=run.workflow_name,
|
|
435
|
+
storage=storage,
|
|
436
|
+
new_args=e.workflow_args,
|
|
437
|
+
new_kwargs=e.workflow_kwargs,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
logger.info(
|
|
441
|
+
f"Workflow continued as new on resume: {run.workflow_name}",
|
|
442
|
+
run_id=run_id,
|
|
443
|
+
workflow_name=run.workflow_name,
|
|
444
|
+
new_run_id=new_run_id,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
return None
|
|
448
|
+
|
|
449
|
+
except Exception as e:
|
|
450
|
+
# Workflow failed
|
|
451
|
+
await storage.update_run_status(run_id=run_id, status=RunStatus.FAILED, error=str(e))
|
|
452
|
+
|
|
453
|
+
# Cancel all running children (TERMINATE policy)
|
|
454
|
+
await _handle_parent_completion_local(run_id, RunStatus.FAILED, storage)
|
|
455
|
+
|
|
456
|
+
logger.error(
|
|
457
|
+
f"Workflow failed on resume: {run.workflow_name}",
|
|
458
|
+
run_id=run_id,
|
|
459
|
+
workflow_name=run.workflow_name,
|
|
460
|
+
error=str(e),
|
|
461
|
+
exc_info=True,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
raise
|
|
465
|
+
|
|
466
|
+
async def schedule_resume(
|
|
467
|
+
self,
|
|
468
|
+
run_id: str,
|
|
469
|
+
storage: "StorageBackend",
|
|
470
|
+
) -> None:
|
|
471
|
+
"""
|
|
472
|
+
Schedule immediate workflow resumption.
|
|
473
|
+
|
|
474
|
+
For local runtime, this directly calls resume_workflow since
|
|
475
|
+
execution happens in-process.
|
|
476
|
+
"""
|
|
477
|
+
logger.info(
|
|
478
|
+
f"Scheduling immediate workflow resume: {run_id}",
|
|
479
|
+
run_id=run_id,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
try:
|
|
483
|
+
await self.resume_workflow(run_id, storage)
|
|
484
|
+
except Exception as e:
|
|
485
|
+
logger.error(
|
|
486
|
+
f"Failed to resume workflow: {e}",
|
|
487
|
+
run_id=run_id,
|
|
488
|
+
exc_info=True,
|
|
489
|
+
)
|
|
490
|
+
raise
|
|
491
|
+
|
|
492
|
+
async def schedule_wake(
|
|
493
|
+
self,
|
|
494
|
+
run_id: str,
|
|
495
|
+
wake_time: datetime,
|
|
496
|
+
storage: "StorageBackend",
|
|
497
|
+
) -> None:
|
|
498
|
+
"""
|
|
499
|
+
Schedule workflow resumption at a specific time.
|
|
500
|
+
|
|
501
|
+
Note: Local runtime cannot auto-schedule wake-ups.
|
|
502
|
+
User must manually call resume().
|
|
503
|
+
"""
|
|
504
|
+
logger.info(
|
|
505
|
+
f"Workflow {run_id} suspended until {wake_time}. "
|
|
506
|
+
"Call resume() manually to continue (local runtime does not support auto-wake).",
|
|
507
|
+
run_id=run_id,
|
|
508
|
+
wake_time=wake_time.isoformat(),
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
async def start_child_workflow(
|
|
512
|
+
self,
|
|
513
|
+
workflow_func: Callable[..., Any],
|
|
514
|
+
args: tuple,
|
|
515
|
+
kwargs: dict,
|
|
516
|
+
child_run_id: str,
|
|
517
|
+
workflow_name: str,
|
|
518
|
+
storage: "StorageBackend",
|
|
519
|
+
parent_run_id: str,
|
|
520
|
+
child_id: str,
|
|
521
|
+
wait_for_completion: bool,
|
|
522
|
+
) -> None:
|
|
523
|
+
"""
|
|
524
|
+
Start a child workflow in the background (fire-and-forget).
|
|
525
|
+
|
|
526
|
+
Uses asyncio.create_task to run the child workflow asynchronously
|
|
527
|
+
so the caller returns immediately.
|
|
528
|
+
"""
|
|
529
|
+
import asyncio
|
|
530
|
+
|
|
531
|
+
asyncio.create_task(
|
|
532
|
+
self._execute_child_workflow(
|
|
533
|
+
workflow_func=workflow_func,
|
|
534
|
+
args=args,
|
|
535
|
+
kwargs=kwargs,
|
|
536
|
+
child_run_id=child_run_id,
|
|
537
|
+
workflow_name=workflow_name,
|
|
538
|
+
storage=storage,
|
|
539
|
+
parent_run_id=parent_run_id,
|
|
540
|
+
child_id=child_id,
|
|
541
|
+
wait_for_completion=wait_for_completion,
|
|
542
|
+
)
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
async def _execute_child_workflow(
|
|
546
|
+
self,
|
|
547
|
+
workflow_func: Callable[..., Any],
|
|
548
|
+
args: tuple,
|
|
549
|
+
kwargs: dict,
|
|
550
|
+
child_run_id: str,
|
|
551
|
+
workflow_name: str,
|
|
552
|
+
storage: "StorageBackend",
|
|
553
|
+
parent_run_id: str,
|
|
554
|
+
child_id: str,
|
|
555
|
+
wait_for_completion: bool,
|
|
556
|
+
) -> None:
|
|
557
|
+
"""
|
|
558
|
+
Execute a child workflow and notify parent on completion.
|
|
559
|
+
|
|
560
|
+
This runs in the background and handles:
|
|
561
|
+
1. Executing the child workflow
|
|
562
|
+
2. Recording completion/failure events in parent's log
|
|
563
|
+
3. Triggering parent resumption if waiting
|
|
564
|
+
"""
|
|
565
|
+
from pyworkflow.core.workflow import execute_workflow_with_context
|
|
566
|
+
from pyworkflow.engine.events import (
|
|
567
|
+
create_child_workflow_completed_event,
|
|
568
|
+
create_child_workflow_failed_event,
|
|
569
|
+
)
|
|
570
|
+
from pyworkflow.serialization.encoder import serialize
|
|
571
|
+
from pyworkflow.storage.schemas import RunStatus
|
|
572
|
+
|
|
573
|
+
try:
|
|
574
|
+
# Update status to RUNNING
|
|
575
|
+
await storage.update_run_status(child_run_id, RunStatus.RUNNING)
|
|
576
|
+
|
|
577
|
+
# Execute the child workflow
|
|
578
|
+
result = await execute_workflow_with_context(
|
|
579
|
+
run_id=child_run_id,
|
|
580
|
+
workflow_func=workflow_func,
|
|
581
|
+
workflow_name=workflow_name,
|
|
582
|
+
args=args,
|
|
583
|
+
kwargs=kwargs,
|
|
584
|
+
storage=storage,
|
|
585
|
+
durable=True,
|
|
586
|
+
event_log=None, # Fresh execution
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
# Update status to COMPLETED
|
|
590
|
+
serialized_result = serialize(result)
|
|
591
|
+
await storage.update_run_status(
|
|
592
|
+
child_run_id, RunStatus.COMPLETED, result=serialized_result
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
# Record completion in parent's log
|
|
596
|
+
completion_event = create_child_workflow_completed_event(
|
|
597
|
+
run_id=parent_run_id,
|
|
598
|
+
child_id=child_id,
|
|
599
|
+
child_run_id=child_run_id,
|
|
600
|
+
result=serialized_result,
|
|
601
|
+
)
|
|
602
|
+
await storage.record_event(completion_event)
|
|
603
|
+
|
|
604
|
+
logger.info(
|
|
605
|
+
f"Child workflow completed: {workflow_name}",
|
|
606
|
+
parent_run_id=parent_run_id,
|
|
607
|
+
child_run_id=child_run_id,
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
# If parent is waiting, trigger resumption
|
|
611
|
+
if wait_for_completion:
|
|
612
|
+
await self._trigger_parent_resumption(parent_run_id, storage)
|
|
613
|
+
|
|
614
|
+
except SuspensionSignal:
|
|
615
|
+
# Child workflow suspended (e.g., sleep, hook)
|
|
616
|
+
# Update status and don't notify parent yet - handled on child resumption
|
|
617
|
+
await storage.update_run_status(child_run_id, RunStatus.SUSPENDED)
|
|
618
|
+
logger.debug(
|
|
619
|
+
f"Child workflow suspended: {workflow_name}",
|
|
620
|
+
parent_run_id=parent_run_id,
|
|
621
|
+
child_run_id=child_run_id,
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
except Exception as e:
|
|
625
|
+
# Child workflow failed
|
|
626
|
+
error_msg = str(e)
|
|
627
|
+
error_type = type(e).__name__
|
|
628
|
+
|
|
629
|
+
await storage.update_run_status(child_run_id, RunStatus.FAILED, error=error_msg)
|
|
630
|
+
|
|
631
|
+
# Record failure in parent's log
|
|
632
|
+
failure_event = create_child_workflow_failed_event(
|
|
633
|
+
run_id=parent_run_id,
|
|
634
|
+
child_id=child_id,
|
|
635
|
+
child_run_id=child_run_id,
|
|
636
|
+
error=error_msg,
|
|
637
|
+
error_type=error_type,
|
|
638
|
+
)
|
|
639
|
+
await storage.record_event(failure_event)
|
|
640
|
+
|
|
641
|
+
logger.error(
|
|
642
|
+
f"Child workflow failed: {workflow_name}",
|
|
643
|
+
parent_run_id=parent_run_id,
|
|
644
|
+
child_run_id=child_run_id,
|
|
645
|
+
error=error_msg,
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
# If parent is waiting, trigger resumption (will raise error on replay)
|
|
649
|
+
if wait_for_completion:
|
|
650
|
+
await self._trigger_parent_resumption(parent_run_id, storage)
|
|
651
|
+
|
|
652
|
+
async def _trigger_parent_resumption(
|
|
653
|
+
self,
|
|
654
|
+
parent_run_id: str,
|
|
655
|
+
storage: "StorageBackend",
|
|
656
|
+
) -> None:
|
|
657
|
+
"""
|
|
658
|
+
Trigger parent workflow resumption after child completes.
|
|
659
|
+
|
|
660
|
+
Checks if parent is suspended and resumes it.
|
|
661
|
+
"""
|
|
662
|
+
from pyworkflow.storage.schemas import RunStatus
|
|
663
|
+
|
|
664
|
+
parent_run = await storage.get_run(parent_run_id)
|
|
665
|
+
if parent_run and parent_run.status == RunStatus.SUSPENDED:
|
|
666
|
+
logger.debug(
|
|
667
|
+
"Triggering parent resumption",
|
|
668
|
+
parent_run_id=parent_run_id,
|
|
669
|
+
)
|
|
670
|
+
# Resume the parent workflow directly (we're already in a background task)
|
|
671
|
+
await self.resume_workflow(parent_run_id, storage=storage)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
async def resume(
|
|
675
|
+
run_id: str,
|
|
676
|
+
storage: Optional["StorageBackend"] = None,
|
|
677
|
+
) -> Any:
|
|
678
|
+
"""
|
|
679
|
+
Resume a suspended workflow using the local runtime.
|
|
680
|
+
|
|
681
|
+
This is a convenience function for resuming workflows without
|
|
682
|
+
explicitly creating a LocalRuntime instance.
|
|
683
|
+
|
|
684
|
+
Args:
|
|
685
|
+
run_id: Workflow run ID to resume
|
|
686
|
+
storage: Storage backend (uses configured default if None)
|
|
687
|
+
|
|
688
|
+
Returns:
|
|
689
|
+
Workflow result if completed, None if suspended again
|
|
690
|
+
|
|
691
|
+
Raises:
|
|
692
|
+
WorkflowNotFoundError: If workflow run doesn't exist
|
|
693
|
+
"""
|
|
694
|
+
if storage is None:
|
|
695
|
+
from pyworkflow.config import get_config
|
|
696
|
+
|
|
697
|
+
config = get_config()
|
|
698
|
+
storage = config.storage
|
|
699
|
+
|
|
700
|
+
if storage is None:
|
|
701
|
+
from pyworkflow.storage.file import FileStorageBackend
|
|
702
|
+
|
|
703
|
+
storage = FileStorageBackend()
|
|
704
|
+
|
|
705
|
+
runtime = LocalRuntime()
|
|
706
|
+
return await runtime.resume_workflow(run_id=run_id, storage=storage)
|