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,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)
|