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.
Files changed (196) hide show
  1. dashboard/backend/app/__init__.py +1 -0
  2. dashboard/backend/app/config.py +32 -0
  3. dashboard/backend/app/controllers/__init__.py +6 -0
  4. dashboard/backend/app/controllers/run_controller.py +86 -0
  5. dashboard/backend/app/controllers/workflow_controller.py +33 -0
  6. dashboard/backend/app/dependencies/__init__.py +5 -0
  7. dashboard/backend/app/dependencies/storage.py +50 -0
  8. dashboard/backend/app/repositories/__init__.py +6 -0
  9. dashboard/backend/app/repositories/run_repository.py +80 -0
  10. dashboard/backend/app/repositories/workflow_repository.py +27 -0
  11. dashboard/backend/app/rest/__init__.py +8 -0
  12. dashboard/backend/app/rest/v1/__init__.py +12 -0
  13. dashboard/backend/app/rest/v1/health.py +33 -0
  14. dashboard/backend/app/rest/v1/runs.py +133 -0
  15. dashboard/backend/app/rest/v1/workflows.py +41 -0
  16. dashboard/backend/app/schemas/__init__.py +23 -0
  17. dashboard/backend/app/schemas/common.py +16 -0
  18. dashboard/backend/app/schemas/event.py +24 -0
  19. dashboard/backend/app/schemas/hook.py +25 -0
  20. dashboard/backend/app/schemas/run.py +54 -0
  21. dashboard/backend/app/schemas/step.py +28 -0
  22. dashboard/backend/app/schemas/workflow.py +31 -0
  23. dashboard/backend/app/server.py +87 -0
  24. dashboard/backend/app/services/__init__.py +6 -0
  25. dashboard/backend/app/services/run_service.py +240 -0
  26. dashboard/backend/app/services/workflow_service.py +155 -0
  27. dashboard/backend/main.py +18 -0
  28. docs/concepts/cancellation.mdx +362 -0
  29. docs/concepts/continue-as-new.mdx +434 -0
  30. docs/concepts/events.mdx +266 -0
  31. docs/concepts/fault-tolerance.mdx +370 -0
  32. docs/concepts/hooks.mdx +552 -0
  33. docs/concepts/limitations.mdx +167 -0
  34. docs/concepts/schedules.mdx +775 -0
  35. docs/concepts/sleep.mdx +312 -0
  36. docs/concepts/steps.mdx +301 -0
  37. docs/concepts/workflows.mdx +255 -0
  38. docs/guides/cli.mdx +942 -0
  39. docs/guides/configuration.mdx +560 -0
  40. docs/introduction.mdx +155 -0
  41. docs/quickstart.mdx +279 -0
  42. examples/__init__.py +1 -0
  43. examples/celery/__init__.py +1 -0
  44. examples/celery/durable/docker-compose.yml +55 -0
  45. examples/celery/durable/pyworkflow.config.yaml +12 -0
  46. examples/celery/durable/workflows/__init__.py +122 -0
  47. examples/celery/durable/workflows/basic.py +87 -0
  48. examples/celery/durable/workflows/batch_processing.py +102 -0
  49. examples/celery/durable/workflows/cancellation.py +273 -0
  50. examples/celery/durable/workflows/child_workflow_patterns.py +240 -0
  51. examples/celery/durable/workflows/child_workflows.py +202 -0
  52. examples/celery/durable/workflows/continue_as_new.py +260 -0
  53. examples/celery/durable/workflows/fault_tolerance.py +210 -0
  54. examples/celery/durable/workflows/hooks.py +211 -0
  55. examples/celery/durable/workflows/idempotency.py +112 -0
  56. examples/celery/durable/workflows/long_running.py +99 -0
  57. examples/celery/durable/workflows/retries.py +101 -0
  58. examples/celery/durable/workflows/schedules.py +209 -0
  59. examples/celery/transient/01_basic_workflow.py +91 -0
  60. examples/celery/transient/02_fault_tolerance.py +257 -0
  61. examples/celery/transient/__init__.py +20 -0
  62. examples/celery/transient/pyworkflow.config.yaml +25 -0
  63. examples/local/__init__.py +1 -0
  64. examples/local/durable/01_basic_workflow.py +94 -0
  65. examples/local/durable/02_file_storage.py +132 -0
  66. examples/local/durable/03_retries.py +169 -0
  67. examples/local/durable/04_long_running.py +119 -0
  68. examples/local/durable/05_event_log.py +145 -0
  69. examples/local/durable/06_idempotency.py +148 -0
  70. examples/local/durable/07_hooks.py +334 -0
  71. examples/local/durable/08_cancellation.py +233 -0
  72. examples/local/durable/09_child_workflows.py +198 -0
  73. examples/local/durable/10_child_workflow_patterns.py +265 -0
  74. examples/local/durable/11_continue_as_new.py +249 -0
  75. examples/local/durable/12_schedules.py +198 -0
  76. examples/local/durable/__init__.py +1 -0
  77. examples/local/transient/01_quick_tasks.py +87 -0
  78. examples/local/transient/02_retries.py +130 -0
  79. examples/local/transient/03_sleep.py +141 -0
  80. examples/local/transient/__init__.py +1 -0
  81. pyworkflow/__init__.py +256 -0
  82. pyworkflow/aws/__init__.py +68 -0
  83. pyworkflow/aws/context.py +234 -0
  84. pyworkflow/aws/handler.py +184 -0
  85. pyworkflow/aws/testing.py +310 -0
  86. pyworkflow/celery/__init__.py +41 -0
  87. pyworkflow/celery/app.py +198 -0
  88. pyworkflow/celery/scheduler.py +315 -0
  89. pyworkflow/celery/tasks.py +1746 -0
  90. pyworkflow/cli/__init__.py +132 -0
  91. pyworkflow/cli/__main__.py +6 -0
  92. pyworkflow/cli/commands/__init__.py +1 -0
  93. pyworkflow/cli/commands/hooks.py +640 -0
  94. pyworkflow/cli/commands/quickstart.py +495 -0
  95. pyworkflow/cli/commands/runs.py +773 -0
  96. pyworkflow/cli/commands/scheduler.py +130 -0
  97. pyworkflow/cli/commands/schedules.py +794 -0
  98. pyworkflow/cli/commands/setup.py +703 -0
  99. pyworkflow/cli/commands/worker.py +413 -0
  100. pyworkflow/cli/commands/workflows.py +1257 -0
  101. pyworkflow/cli/output/__init__.py +1 -0
  102. pyworkflow/cli/output/formatters.py +321 -0
  103. pyworkflow/cli/output/styles.py +121 -0
  104. pyworkflow/cli/utils/__init__.py +1 -0
  105. pyworkflow/cli/utils/async_helpers.py +30 -0
  106. pyworkflow/cli/utils/config.py +130 -0
  107. pyworkflow/cli/utils/config_generator.py +344 -0
  108. pyworkflow/cli/utils/discovery.py +53 -0
  109. pyworkflow/cli/utils/docker_manager.py +651 -0
  110. pyworkflow/cli/utils/interactive.py +364 -0
  111. pyworkflow/cli/utils/storage.py +115 -0
  112. pyworkflow/config.py +329 -0
  113. pyworkflow/context/__init__.py +63 -0
  114. pyworkflow/context/aws.py +230 -0
  115. pyworkflow/context/base.py +416 -0
  116. pyworkflow/context/local.py +930 -0
  117. pyworkflow/context/mock.py +381 -0
  118. pyworkflow/core/__init__.py +0 -0
  119. pyworkflow/core/exceptions.py +353 -0
  120. pyworkflow/core/registry.py +313 -0
  121. pyworkflow/core/scheduled.py +328 -0
  122. pyworkflow/core/step.py +494 -0
  123. pyworkflow/core/workflow.py +294 -0
  124. pyworkflow/discovery.py +248 -0
  125. pyworkflow/engine/__init__.py +0 -0
  126. pyworkflow/engine/events.py +879 -0
  127. pyworkflow/engine/executor.py +682 -0
  128. pyworkflow/engine/replay.py +273 -0
  129. pyworkflow/observability/__init__.py +19 -0
  130. pyworkflow/observability/logging.py +234 -0
  131. pyworkflow/primitives/__init__.py +33 -0
  132. pyworkflow/primitives/child_handle.py +174 -0
  133. pyworkflow/primitives/child_workflow.py +372 -0
  134. pyworkflow/primitives/continue_as_new.py +101 -0
  135. pyworkflow/primitives/define_hook.py +150 -0
  136. pyworkflow/primitives/hooks.py +97 -0
  137. pyworkflow/primitives/resume_hook.py +210 -0
  138. pyworkflow/primitives/schedule.py +545 -0
  139. pyworkflow/primitives/shield.py +96 -0
  140. pyworkflow/primitives/sleep.py +100 -0
  141. pyworkflow/runtime/__init__.py +21 -0
  142. pyworkflow/runtime/base.py +179 -0
  143. pyworkflow/runtime/celery.py +310 -0
  144. pyworkflow/runtime/factory.py +101 -0
  145. pyworkflow/runtime/local.py +706 -0
  146. pyworkflow/scheduler/__init__.py +9 -0
  147. pyworkflow/scheduler/local.py +248 -0
  148. pyworkflow/serialization/__init__.py +0 -0
  149. pyworkflow/serialization/decoder.py +146 -0
  150. pyworkflow/serialization/encoder.py +162 -0
  151. pyworkflow/storage/__init__.py +54 -0
  152. pyworkflow/storage/base.py +612 -0
  153. pyworkflow/storage/config.py +185 -0
  154. pyworkflow/storage/dynamodb.py +1315 -0
  155. pyworkflow/storage/file.py +827 -0
  156. pyworkflow/storage/memory.py +549 -0
  157. pyworkflow/storage/postgres.py +1161 -0
  158. pyworkflow/storage/schemas.py +486 -0
  159. pyworkflow/storage/sqlite.py +1136 -0
  160. pyworkflow/utils/__init__.py +0 -0
  161. pyworkflow/utils/duration.py +177 -0
  162. pyworkflow/utils/schedule.py +391 -0
  163. pyworkflow_engine-0.1.7.dist-info/METADATA +687 -0
  164. pyworkflow_engine-0.1.7.dist-info/RECORD +196 -0
  165. pyworkflow_engine-0.1.7.dist-info/WHEEL +5 -0
  166. pyworkflow_engine-0.1.7.dist-info/entry_points.txt +2 -0
  167. pyworkflow_engine-0.1.7.dist-info/licenses/LICENSE +21 -0
  168. pyworkflow_engine-0.1.7.dist-info/top_level.txt +5 -0
  169. tests/examples/__init__.py +0 -0
  170. tests/integration/__init__.py +0 -0
  171. tests/integration/test_cancellation.py +330 -0
  172. tests/integration/test_child_workflows.py +439 -0
  173. tests/integration/test_continue_as_new.py +428 -0
  174. tests/integration/test_dynamodb_storage.py +1146 -0
  175. tests/integration/test_fault_tolerance.py +369 -0
  176. tests/integration/test_schedule_storage.py +484 -0
  177. tests/unit/__init__.py +0 -0
  178. tests/unit/backends/__init__.py +1 -0
  179. tests/unit/backends/test_dynamodb_storage.py +1554 -0
  180. tests/unit/backends/test_postgres_storage.py +1281 -0
  181. tests/unit/backends/test_sqlite_storage.py +1460 -0
  182. tests/unit/conftest.py +41 -0
  183. tests/unit/test_cancellation.py +364 -0
  184. tests/unit/test_child_workflows.py +680 -0
  185. tests/unit/test_continue_as_new.py +441 -0
  186. tests/unit/test_event_limits.py +316 -0
  187. tests/unit/test_executor.py +320 -0
  188. tests/unit/test_fault_tolerance.py +334 -0
  189. tests/unit/test_hooks.py +495 -0
  190. tests/unit/test_registry.py +261 -0
  191. tests/unit/test_replay.py +420 -0
  192. tests/unit/test_schedule_schemas.py +285 -0
  193. tests/unit/test_schedule_utils.py +286 -0
  194. tests/unit/test_scheduled_workflow.py +274 -0
  195. tests/unit/test_step.py +353 -0
  196. 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)
@@ -0,0 +1,9 @@
1
+ """
2
+ Scheduler implementations for PyWorkflow.
3
+
4
+ Provides scheduler classes that poll storage for due schedules and trigger workflows.
5
+ """
6
+
7
+ from pyworkflow.scheduler.local import LocalScheduler
8
+
9
+ __all__ = ["LocalScheduler"]