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,174 @@
1
+ """
2
+ ChildWorkflowHandle for fire-and-forget child workflow pattern.
3
+
4
+ When start_child_workflow() is called with wait_for_completion=False,
5
+ it returns a handle that can be used to query status, get results, or cancel.
6
+ """
7
+
8
+ import asyncio
9
+ from dataclasses import dataclass
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from pyworkflow.core.exceptions import ChildWorkflowFailedError
13
+ from pyworkflow.storage.schemas import RunStatus
14
+
15
+ if TYPE_CHECKING:
16
+ from pyworkflow.storage.base import StorageBackend
17
+
18
+
19
+ @dataclass
20
+ class ChildWorkflowHandle:
21
+ """
22
+ Handle for a child workflow that was started without waiting.
23
+
24
+ Provides methods to query status, await completion, or cancel
25
+ the child workflow.
26
+
27
+ Attributes:
28
+ child_id: Deterministic child identifier (for replay)
29
+ child_run_id: The child workflow's unique run ID
30
+ child_workflow_name: The name of the child workflow
31
+ parent_run_id: The parent workflow's run ID
32
+
33
+ Example:
34
+ # Fire-and-forget pattern
35
+ handle = await start_child_workflow(
36
+ my_workflow,
37
+ arg1, arg2,
38
+ wait_for_completion=False
39
+ )
40
+
41
+ # Do other work...
42
+ await do_other_work()
43
+
44
+ # Later, check status or get result
45
+ status = await handle.get_status()
46
+ if status == RunStatus.COMPLETED:
47
+ result = await handle.result()
48
+
49
+ # Or cancel if needed
50
+ await handle.cancel()
51
+ """
52
+
53
+ child_id: str
54
+ child_run_id: str
55
+ child_workflow_name: str
56
+ parent_run_id: str
57
+ _storage: "StorageBackend"
58
+
59
+ async def get_status(self) -> RunStatus:
60
+ """
61
+ Get current status of the child workflow.
62
+
63
+ Returns:
64
+ Current RunStatus of the child workflow
65
+
66
+ Raises:
67
+ ValueError: If child workflow not found
68
+ """
69
+ run = await self._storage.get_run(self.child_run_id)
70
+ if run is None:
71
+ raise ValueError(f"Child workflow {self.child_run_id} not found")
72
+ return run.status
73
+
74
+ async def result(self, timeout: float | None = None) -> Any:
75
+ """
76
+ Wait for child workflow to complete and return result.
77
+
78
+ Polls the storage for child completion. For long timeouts,
79
+ consider using a hook-based approach instead.
80
+
81
+ Args:
82
+ timeout: Maximum seconds to wait (None = wait forever)
83
+
84
+ Returns:
85
+ The child workflow's result
86
+
87
+ Raises:
88
+ ChildWorkflowFailedError: If child failed or was cancelled
89
+ TimeoutError: If timeout exceeded
90
+ ValueError: If child workflow not found
91
+ """
92
+ from pyworkflow.serialization.decoder import deserialize
93
+
94
+ poll_interval = 0.5
95
+ elapsed = 0.0
96
+
97
+ while True:
98
+ run = await self._storage.get_run(self.child_run_id)
99
+ if run is None:
100
+ raise ValueError(f"Child workflow {self.child_run_id} not found")
101
+
102
+ if run.status == RunStatus.COMPLETED:
103
+ return deserialize(run.result) if run.result else None
104
+
105
+ if run.status == RunStatus.FAILED:
106
+ raise ChildWorkflowFailedError(
107
+ child_run_id=self.child_run_id,
108
+ child_workflow_name=self.child_workflow_name,
109
+ error=run.error or "Unknown error",
110
+ error_type="Unknown",
111
+ )
112
+
113
+ if run.status == RunStatus.CANCELLED:
114
+ raise ChildWorkflowFailedError(
115
+ child_run_id=self.child_run_id,
116
+ child_workflow_name=self.child_workflow_name,
117
+ error="Child workflow was cancelled",
118
+ error_type="CancellationError",
119
+ )
120
+
121
+ if timeout is not None and elapsed >= timeout:
122
+ raise TimeoutError(
123
+ f"Child workflow {self.child_run_id} did not complete within {timeout}s"
124
+ )
125
+
126
+ await asyncio.sleep(poll_interval)
127
+ elapsed += poll_interval
128
+
129
+ async def cancel(self, reason: str | None = None) -> bool:
130
+ """
131
+ Request cancellation of the child workflow.
132
+
133
+ Args:
134
+ reason: Optional cancellation reason
135
+
136
+ Returns:
137
+ True if cancellation was initiated, False if already terminal
138
+ """
139
+ from pyworkflow.engine.executor import cancel_workflow
140
+
141
+ return await cancel_workflow(
142
+ run_id=self.child_run_id,
143
+ reason=reason,
144
+ storage=self._storage,
145
+ )
146
+
147
+ async def is_running(self) -> bool:
148
+ """
149
+ Check if child workflow is still running.
150
+
151
+ Returns:
152
+ True if running or suspended, False if terminal
153
+ """
154
+ status = await self.get_status()
155
+ return status in {RunStatus.PENDING, RunStatus.RUNNING, RunStatus.SUSPENDED}
156
+
157
+ async def is_terminal(self) -> bool:
158
+ """
159
+ Check if child workflow has reached a terminal state.
160
+
161
+ Returns:
162
+ True if completed, failed, or cancelled
163
+ """
164
+ status = await self.get_status()
165
+ return status in {RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.CANCELLED}
166
+
167
+ def __repr__(self) -> str:
168
+ """Return string representation."""
169
+ return (
170
+ f"ChildWorkflowHandle("
171
+ f"child_id={self.child_id!r}, "
172
+ f"child_run_id={self.child_run_id!r}, "
173
+ f"child_workflow_name={self.child_workflow_name!r})"
174
+ )
@@ -0,0 +1,372 @@
1
+ """
2
+ start_child_workflow() primitive for spawning child workflows.
3
+
4
+ Child workflows have their own run_id and event history but are linked
5
+ to their parent for lifecycle management. When parent completes/fails/cancels,
6
+ all running children are automatically cancelled (TERMINATE policy).
7
+ """
8
+
9
+ import hashlib
10
+ import uuid
11
+ from collections.abc import Callable
12
+ from datetime import UTC, datetime
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from loguru import logger
16
+
17
+ from pyworkflow.context import get_context, has_context
18
+ from pyworkflow.core.exceptions import (
19
+ ChildWorkflowFailedError,
20
+ MaxNestingDepthError,
21
+ SuspensionSignal,
22
+ )
23
+ from pyworkflow.core.registry import get_workflow_by_func
24
+ from pyworkflow.engine.events import create_child_workflow_started_event
25
+ from pyworkflow.primitives.child_handle import ChildWorkflowHandle
26
+ from pyworkflow.serialization.encoder import serialize_args, serialize_kwargs
27
+ from pyworkflow.storage.schemas import RunStatus, WorkflowRun
28
+
29
+ if TYPE_CHECKING:
30
+ from pyworkflow.core.registry import WorkflowMetadata
31
+
32
+
33
+ MAX_NESTING_DEPTH = 3
34
+
35
+
36
+ async def start_child_workflow(
37
+ workflow_func: Callable,
38
+ *args: Any,
39
+ wait_for_completion: bool = True,
40
+ **kwargs: Any,
41
+ ) -> Any | ChildWorkflowHandle:
42
+ """
43
+ Start a child workflow from within a parent workflow.
44
+
45
+ Child workflows have their own run_id and event history but are linked
46
+ to the parent for lifecycle management. When the parent completes, fails,
47
+ or is cancelled, all running children are automatically cancelled.
48
+
49
+ Args:
50
+ workflow_func: Workflow function decorated with @workflow
51
+ *args: Positional arguments for the child workflow
52
+ wait_for_completion: If True, suspend until child completes and return result.
53
+ If False, return ChildWorkflowHandle immediately for fire-and-forget.
54
+ **kwargs: Keyword arguments for the child workflow
55
+
56
+ Returns:
57
+ If wait_for_completion=True: Child workflow result
58
+ If wait_for_completion=False: ChildWorkflowHandle
59
+
60
+ Raises:
61
+ RuntimeError: If called outside workflow context
62
+ MaxNestingDepthError: If max nesting depth (3) exceeded
63
+ ChildWorkflowFailedError: If wait_for_completion=True and child fails
64
+ ValueError: If workflow_func is not a registered workflow
65
+
66
+ Examples:
67
+ # Wait for child to complete (default)
68
+ result = await start_child_workflow(process_order, order_id)
69
+
70
+ # Fire-and-forget with handle
71
+ handle = await start_child_workflow(
72
+ send_notifications,
73
+ order_id,
74
+ wait_for_completion=False
75
+ )
76
+
77
+ # Check status or get result later
78
+ if await handle.get_status() == RunStatus.COMPLETED:
79
+ result = await handle.result()
80
+
81
+ # Or cancel if needed
82
+ await handle.cancel()
83
+ """
84
+ if not has_context():
85
+ raise RuntimeError(
86
+ "start_child_workflow() must be called within a workflow context. "
87
+ "Make sure you're using the @workflow decorator."
88
+ )
89
+
90
+ ctx = get_context()
91
+
92
+ # Validate storage is available (required for child workflows)
93
+ storage = ctx.storage
94
+ if storage is None:
95
+ raise RuntimeError(
96
+ "start_child_workflow() requires durable mode with storage. "
97
+ "Make sure you have configured a storage backend."
98
+ )
99
+
100
+ # Check for cancellation before starting child
101
+ ctx.check_cancellation()
102
+
103
+ # Get workflow metadata
104
+ workflow_meta = get_workflow_by_func(workflow_func)
105
+ if not workflow_meta:
106
+ raise ValueError(
107
+ f"Function {workflow_func.__name__} is not registered as a workflow. "
108
+ f"Did you forget the @workflow decorator?"
109
+ )
110
+
111
+ child_workflow_name = workflow_meta.name
112
+
113
+ # Enforce max nesting depth
114
+ current_depth = await storage.get_nesting_depth(ctx.run_id)
115
+ if current_depth >= MAX_NESTING_DEPTH:
116
+ raise MaxNestingDepthError(current_depth)
117
+
118
+ # Generate deterministic child_id (like step_id)
119
+ child_id = _generate_child_id(child_workflow_name, args, kwargs)
120
+
121
+ # Check if child already completed (replay from events)
122
+ if ctx.has_child_result(child_id):
123
+ logger.debug(
124
+ f"[replay] Child workflow {child_id} already completed",
125
+ run_id=ctx.run_id,
126
+ child_id=child_id,
127
+ )
128
+ cached = ctx.get_child_result(child_id)
129
+
130
+ if wait_for_completion:
131
+ # Check if it was a failure
132
+ if cached.get("__failed__"):
133
+ raise ChildWorkflowFailedError(
134
+ child_run_id=cached["child_run_id"],
135
+ child_workflow_name=child_workflow_name,
136
+ error=cached["error"],
137
+ error_type=cached["error_type"],
138
+ )
139
+ return cached["result"]
140
+ else:
141
+ # Return handle to existing child
142
+ return ChildWorkflowHandle(
143
+ child_id=child_id,
144
+ child_run_id=cached["child_run_id"],
145
+ child_workflow_name=child_workflow_name,
146
+ parent_run_id=ctx.run_id,
147
+ _storage=storage,
148
+ )
149
+
150
+ # Check if child is pending (started but not completed in events)
151
+ # This can happen during recovery - the child might still be running
152
+ # or might have completed while we were recovering
153
+ if child_id in ctx.pending_children:
154
+ existing_child_run_id = ctx.pending_children[child_id]
155
+ child_run = await storage.get_run(existing_child_run_id)
156
+
157
+ if child_run:
158
+ logger.debug(
159
+ f"[recovery] Found pending child {child_id} with status {child_run.status}",
160
+ run_id=ctx.run_id,
161
+ child_run_id=existing_child_run_id,
162
+ )
163
+
164
+ if child_run.status == RunStatus.COMPLETED:
165
+ # Child completed while we were recovering - use its result
166
+ from pyworkflow.serialization.decoder import deserialize
167
+
168
+ result = deserialize(child_run.result) if child_run.result else None
169
+ logger.info(
170
+ f"[recovery] Child {child_id} already completed, using cached result",
171
+ run_id=ctx.run_id,
172
+ child_run_id=existing_child_run_id,
173
+ )
174
+
175
+ if wait_for_completion:
176
+ return result
177
+ else:
178
+ return ChildWorkflowHandle(
179
+ child_id=child_id,
180
+ child_run_id=existing_child_run_id,
181
+ child_workflow_name=child_workflow_name,
182
+ parent_run_id=ctx.run_id,
183
+ _storage=storage,
184
+ )
185
+
186
+ elif child_run.status == RunStatus.FAILED:
187
+ # Child failed while we were recovering
188
+ logger.info(
189
+ f"[recovery] Child {child_id} already failed",
190
+ run_id=ctx.run_id,
191
+ child_run_id=existing_child_run_id,
192
+ )
193
+ if wait_for_completion:
194
+ raise ChildWorkflowFailedError(
195
+ child_run_id=existing_child_run_id,
196
+ child_workflow_name=child_workflow_name,
197
+ error=child_run.error or "Unknown error",
198
+ error_type="ChildWorkflowError",
199
+ )
200
+ else:
201
+ return ChildWorkflowHandle(
202
+ child_id=child_id,
203
+ child_run_id=existing_child_run_id,
204
+ child_workflow_name=child_workflow_name,
205
+ parent_run_id=ctx.run_id,
206
+ _storage=storage,
207
+ )
208
+
209
+ elif child_run.status in (
210
+ RunStatus.PENDING,
211
+ RunStatus.RUNNING,
212
+ RunStatus.SUSPENDED,
213
+ ):
214
+ # Child is still running - wait for it, don't start a new one
215
+ logger.info(
216
+ f"[recovery] Child {child_id} still running, waiting for existing child",
217
+ run_id=ctx.run_id,
218
+ child_run_id=existing_child_run_id,
219
+ child_status=child_run.status.value,
220
+ )
221
+
222
+ if not wait_for_completion:
223
+ return ChildWorkflowHandle(
224
+ child_id=child_id,
225
+ child_run_id=existing_child_run_id,
226
+ child_workflow_name=child_workflow_name,
227
+ parent_run_id=ctx.run_id,
228
+ _storage=storage,
229
+ )
230
+
231
+ # Suspend to wait for the existing child
232
+ raise SuspensionSignal(
233
+ reason=f"child_workflow:{child_id}",
234
+ child_id=child_id,
235
+ child_run_id=existing_child_run_id,
236
+ child_workflow_name=child_workflow_name,
237
+ )
238
+
239
+ # Start the child workflow
240
+ child_run_id = await _start_child_on_worker(
241
+ ctx=ctx,
242
+ storage=storage,
243
+ child_id=child_id,
244
+ workflow_meta=workflow_meta,
245
+ args=args,
246
+ kwargs=kwargs,
247
+ wait_for_completion=wait_for_completion,
248
+ )
249
+
250
+ if not wait_for_completion:
251
+ # Fire-and-forget: return handle immediately
252
+ logger.info(
253
+ f"Started child workflow (fire-and-forget): {child_workflow_name}",
254
+ parent_run_id=ctx.run_id,
255
+ child_run_id=child_run_id,
256
+ child_id=child_id,
257
+ )
258
+ return ChildWorkflowHandle(
259
+ child_id=child_id,
260
+ child_run_id=child_run_id,
261
+ child_workflow_name=child_workflow_name,
262
+ parent_run_id=ctx.run_id,
263
+ _storage=storage,
264
+ )
265
+
266
+ # Wait for completion: suspend parent
267
+ logger.info(
268
+ f"Started child workflow (waiting): {child_workflow_name}",
269
+ parent_run_id=ctx.run_id,
270
+ child_run_id=child_run_id,
271
+ child_id=child_id,
272
+ )
273
+
274
+ # Raise suspension to wait for child
275
+ raise SuspensionSignal(
276
+ reason=f"child_workflow:{child_id}",
277
+ child_id=child_id,
278
+ child_run_id=child_run_id,
279
+ child_workflow_name=child_workflow_name,
280
+ )
281
+
282
+
283
+ def _generate_child_id(workflow_name: str, args: tuple, kwargs: dict) -> str:
284
+ """
285
+ Generate deterministic child ID based on workflow name and arguments.
286
+
287
+ This ensures the same child workflow with the same arguments always
288
+ gets the same ID, enabling proper replay behavior.
289
+ """
290
+ args_str = serialize_args(*args)
291
+ kwargs_str = serialize_kwargs(**kwargs)
292
+ content = f"child:{workflow_name}:{args_str}:{kwargs_str}"
293
+ hash_hex = hashlib.sha256(content.encode()).hexdigest()[:16]
294
+ return f"child_{workflow_name}_{hash_hex}"
295
+
296
+
297
+ async def _start_child_on_worker(
298
+ ctx: Any,
299
+ storage: Any,
300
+ child_id: str,
301
+ workflow_meta: "WorkflowMetadata",
302
+ args: tuple[Any, ...],
303
+ kwargs: dict[str, Any],
304
+ wait_for_completion: bool,
305
+ ) -> str:
306
+ """
307
+ Start child workflow execution and record events.
308
+
309
+ This function:
310
+ 1. Generates a unique child run_id
311
+ 2. Records CHILD_WORKFLOW_STARTED event in parent's log
312
+ 3. Creates the child WorkflowRun record
313
+ 4. Schedules child execution (via runtime)
314
+ """
315
+ # Generate child run_id
316
+ child_run_id = f"run_{uuid.uuid4().hex[:16]}"
317
+
318
+ # Get parent's nesting depth
319
+ parent_depth = await storage.get_nesting_depth(ctx.run_id)
320
+ child_depth = parent_depth + 1
321
+
322
+ # Serialize arguments
323
+ args_json = serialize_args(*args)
324
+ kwargs_json = serialize_kwargs(**kwargs)
325
+
326
+ # Record CHILD_WORKFLOW_STARTED event in parent's log
327
+ start_event = create_child_workflow_started_event(
328
+ run_id=ctx.run_id,
329
+ child_id=child_id,
330
+ child_run_id=child_run_id,
331
+ child_workflow_name=workflow_meta.name,
332
+ args=args_json,
333
+ kwargs=kwargs_json,
334
+ wait_for_completion=wait_for_completion,
335
+ )
336
+ await storage.record_event(start_event)
337
+
338
+ # Create child workflow run record
339
+ child_run = WorkflowRun(
340
+ run_id=child_run_id,
341
+ workflow_name=workflow_meta.name,
342
+ status=RunStatus.PENDING,
343
+ created_at=datetime.now(UTC),
344
+ input_args=args_json,
345
+ input_kwargs=kwargs_json,
346
+ parent_run_id=ctx.run_id,
347
+ nesting_depth=child_depth,
348
+ max_duration=workflow_meta.max_duration,
349
+ metadata={}, # Run-level metadata
350
+ )
351
+ await storage.create_run(child_run)
352
+
353
+ # Delegate child workflow execution to the runtime
354
+ from pyworkflow.config import get_config
355
+ from pyworkflow.runtime import get_runtime
356
+
357
+ config = get_config()
358
+ runtime = get_runtime(config.default_runtime)
359
+
360
+ await runtime.start_child_workflow(
361
+ workflow_func=workflow_meta.func,
362
+ args=args,
363
+ kwargs=kwargs,
364
+ child_run_id=child_run_id,
365
+ workflow_name=workflow_meta.name,
366
+ storage=storage,
367
+ parent_run_id=ctx.run_id,
368
+ child_id=child_id,
369
+ wait_for_completion=wait_for_completion,
370
+ )
371
+
372
+ return child_run_id
@@ -0,0 +1,101 @@
1
+ """
2
+ continue_as_new() primitive for workflow continuation.
3
+
4
+ Allows workflows to terminate current execution and start a new run
5
+ with fresh event history. Essential for long-running workflows that
6
+ would otherwise accumulate unbounded event history.
7
+ """
8
+
9
+ from typing import Any, NoReturn
10
+
11
+ from loguru import logger
12
+
13
+ from pyworkflow.context import get_context, has_context
14
+ from pyworkflow.core.exceptions import ContinueAsNewSignal
15
+
16
+
17
+ def continue_as_new(*args: Any, **kwargs: Any) -> NoReturn:
18
+ """
19
+ Complete current workflow and start a new execution with fresh event history.
20
+
21
+ This function never returns - it raises ContinueAsNewSignal which is caught
22
+ by the executor. The current workflow is marked as CONTINUED_AS_NEW and a
23
+ new run is started with the provided arguments.
24
+
25
+ At least one argument must be provided - explicit args are required.
26
+
27
+ Use this for:
28
+ - Long-running polling loops that would accumulate many events
29
+ - Recurring scheduled tasks (daily reports, weekly cleanup)
30
+ - Any workflow that processes data in batches and needs to continue
31
+
32
+ Args:
33
+ *args: Positional arguments for the new workflow execution
34
+ **kwargs: Keyword arguments for the new workflow execution
35
+
36
+ Raises:
37
+ ContinueAsNewSignal: Always (this function never returns)
38
+ RuntimeError: If called outside workflow context
39
+ ValueError: If no arguments are provided
40
+
41
+ Examples:
42
+ @workflow
43
+ async def polling_workflow(cursor: str | None = None):
44
+ # Process current batch
45
+ new_cursor, items = await fetch_items(cursor)
46
+ for item in items:
47
+ await process_item(item)
48
+
49
+ # Continue with new cursor if more items
50
+ if new_cursor:
51
+ continue_as_new(cursor=new_cursor)
52
+
53
+ return "done"
54
+
55
+ @workflow
56
+ async def daily_report_workflow(date: str):
57
+ await generate_report(date)
58
+ await sleep("24h")
59
+
60
+ # Continue with next day
61
+ next_date = get_next_date(date)
62
+ continue_as_new(date=next_date)
63
+
64
+ @workflow
65
+ async def batch_processor(offset: int = 0, batch_size: int = 100):
66
+ items = await fetch_batch(offset, batch_size)
67
+
68
+ if items:
69
+ for item in items:
70
+ await process_item(item)
71
+ # Continue with next batch
72
+ continue_as_new(offset=offset + batch_size, batch_size=batch_size)
73
+
74
+ return f"Processed {offset} items total"
75
+ """
76
+ if not has_context():
77
+ raise RuntimeError(
78
+ "continue_as_new() must be called within a workflow context. "
79
+ "Make sure you're using the @workflow decorator."
80
+ )
81
+
82
+ if not args and not kwargs:
83
+ raise ValueError(
84
+ "continue_as_new() requires at least one argument. "
85
+ "Pass the arguments for the new workflow execution."
86
+ )
87
+
88
+ ctx = get_context()
89
+
90
+ # Check for cancellation - don't continue if cancelled
91
+ ctx.check_cancellation()
92
+
93
+ logger.info(
94
+ "Workflow continuing as new execution",
95
+ run_id=ctx.run_id,
96
+ workflow_name=ctx.workflow_name,
97
+ new_args=args,
98
+ new_kwargs=kwargs,
99
+ )
100
+
101
+ raise ContinueAsNewSignal(workflow_args=args, workflow_kwargs=kwargs)