pyworkflow-engine 0.1.7__py3-none-any.whl → 0.1.10__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.
- pyworkflow/__init__.py +10 -1
- pyworkflow/celery/tasks.py +272 -24
- pyworkflow/cli/__init__.py +4 -1
- pyworkflow/cli/commands/runs.py +4 -4
- pyworkflow/cli/commands/setup.py +203 -4
- pyworkflow/cli/utils/config_generator.py +76 -3
- pyworkflow/cli/utils/docker_manager.py +232 -0
- pyworkflow/config.py +94 -17
- pyworkflow/context/__init__.py +13 -0
- pyworkflow/context/base.py +26 -0
- pyworkflow/context/local.py +80 -0
- pyworkflow/context/step_context.py +295 -0
- pyworkflow/core/registry.py +6 -1
- pyworkflow/core/step.py +141 -0
- pyworkflow/core/workflow.py +56 -0
- pyworkflow/engine/events.py +30 -0
- pyworkflow/engine/replay.py +39 -0
- pyworkflow/primitives/child_workflow.py +1 -1
- pyworkflow/runtime/local.py +1 -1
- pyworkflow/storage/__init__.py +14 -0
- pyworkflow/storage/base.py +35 -0
- pyworkflow/storage/cassandra.py +1747 -0
- pyworkflow/storage/config.py +69 -0
- pyworkflow/storage/dynamodb.py +31 -2
- pyworkflow/storage/file.py +28 -0
- pyworkflow/storage/memory.py +18 -0
- pyworkflow/storage/mysql.py +1159 -0
- pyworkflow/storage/postgres.py +27 -2
- pyworkflow/storage/schemas.py +4 -3
- pyworkflow/storage/sqlite.py +25 -2
- {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.10.dist-info}/METADATA +7 -4
- pyworkflow_engine-0.1.10.dist-info/RECORD +91 -0
- pyworkflow_engine-0.1.10.dist-info/top_level.txt +1 -0
- dashboard/backend/app/__init__.py +0 -1
- dashboard/backend/app/config.py +0 -32
- dashboard/backend/app/controllers/__init__.py +0 -6
- dashboard/backend/app/controllers/run_controller.py +0 -86
- dashboard/backend/app/controllers/workflow_controller.py +0 -33
- dashboard/backend/app/dependencies/__init__.py +0 -5
- dashboard/backend/app/dependencies/storage.py +0 -50
- dashboard/backend/app/repositories/__init__.py +0 -6
- dashboard/backend/app/repositories/run_repository.py +0 -80
- dashboard/backend/app/repositories/workflow_repository.py +0 -27
- dashboard/backend/app/rest/__init__.py +0 -8
- dashboard/backend/app/rest/v1/__init__.py +0 -12
- dashboard/backend/app/rest/v1/health.py +0 -33
- dashboard/backend/app/rest/v1/runs.py +0 -133
- dashboard/backend/app/rest/v1/workflows.py +0 -41
- dashboard/backend/app/schemas/__init__.py +0 -23
- dashboard/backend/app/schemas/common.py +0 -16
- dashboard/backend/app/schemas/event.py +0 -24
- dashboard/backend/app/schemas/hook.py +0 -25
- dashboard/backend/app/schemas/run.py +0 -54
- dashboard/backend/app/schemas/step.py +0 -28
- dashboard/backend/app/schemas/workflow.py +0 -31
- dashboard/backend/app/server.py +0 -87
- dashboard/backend/app/services/__init__.py +0 -6
- dashboard/backend/app/services/run_service.py +0 -240
- dashboard/backend/app/services/workflow_service.py +0 -155
- dashboard/backend/main.py +0 -18
- docs/concepts/cancellation.mdx +0 -362
- docs/concepts/continue-as-new.mdx +0 -434
- docs/concepts/events.mdx +0 -266
- docs/concepts/fault-tolerance.mdx +0 -370
- docs/concepts/hooks.mdx +0 -552
- docs/concepts/limitations.mdx +0 -167
- docs/concepts/schedules.mdx +0 -775
- docs/concepts/sleep.mdx +0 -312
- docs/concepts/steps.mdx +0 -301
- docs/concepts/workflows.mdx +0 -255
- docs/guides/cli.mdx +0 -942
- docs/guides/configuration.mdx +0 -560
- docs/introduction.mdx +0 -155
- docs/quickstart.mdx +0 -279
- examples/__init__.py +0 -1
- examples/celery/__init__.py +0 -1
- examples/celery/durable/docker-compose.yml +0 -55
- examples/celery/durable/pyworkflow.config.yaml +0 -12
- examples/celery/durable/workflows/__init__.py +0 -122
- examples/celery/durable/workflows/basic.py +0 -87
- examples/celery/durable/workflows/batch_processing.py +0 -102
- examples/celery/durable/workflows/cancellation.py +0 -273
- examples/celery/durable/workflows/child_workflow_patterns.py +0 -240
- examples/celery/durable/workflows/child_workflows.py +0 -202
- examples/celery/durable/workflows/continue_as_new.py +0 -260
- examples/celery/durable/workflows/fault_tolerance.py +0 -210
- examples/celery/durable/workflows/hooks.py +0 -211
- examples/celery/durable/workflows/idempotency.py +0 -112
- examples/celery/durable/workflows/long_running.py +0 -99
- examples/celery/durable/workflows/retries.py +0 -101
- examples/celery/durable/workflows/schedules.py +0 -209
- examples/celery/transient/01_basic_workflow.py +0 -91
- examples/celery/transient/02_fault_tolerance.py +0 -257
- examples/celery/transient/__init__.py +0 -20
- examples/celery/transient/pyworkflow.config.yaml +0 -25
- examples/local/__init__.py +0 -1
- examples/local/durable/01_basic_workflow.py +0 -94
- examples/local/durable/02_file_storage.py +0 -132
- examples/local/durable/03_retries.py +0 -169
- examples/local/durable/04_long_running.py +0 -119
- examples/local/durable/05_event_log.py +0 -145
- examples/local/durable/06_idempotency.py +0 -148
- examples/local/durable/07_hooks.py +0 -334
- examples/local/durable/08_cancellation.py +0 -233
- examples/local/durable/09_child_workflows.py +0 -198
- examples/local/durable/10_child_workflow_patterns.py +0 -265
- examples/local/durable/11_continue_as_new.py +0 -249
- examples/local/durable/12_schedules.py +0 -198
- examples/local/durable/__init__.py +0 -1
- examples/local/transient/01_quick_tasks.py +0 -87
- examples/local/transient/02_retries.py +0 -130
- examples/local/transient/03_sleep.py +0 -141
- examples/local/transient/__init__.py +0 -1
- pyworkflow_engine-0.1.7.dist-info/RECORD +0 -196
- pyworkflow_engine-0.1.7.dist-info/top_level.txt +0 -5
- tests/examples/__init__.py +0 -0
- tests/integration/__init__.py +0 -0
- tests/integration/test_cancellation.py +0 -330
- tests/integration/test_child_workflows.py +0 -439
- tests/integration/test_continue_as_new.py +0 -428
- tests/integration/test_dynamodb_storage.py +0 -1146
- tests/integration/test_fault_tolerance.py +0 -369
- tests/integration/test_schedule_storage.py +0 -484
- tests/unit/__init__.py +0 -0
- tests/unit/backends/__init__.py +0 -1
- tests/unit/backends/test_dynamodb_storage.py +0 -1554
- tests/unit/backends/test_postgres_storage.py +0 -1281
- tests/unit/backends/test_sqlite_storage.py +0 -1460
- tests/unit/conftest.py +0 -41
- tests/unit/test_cancellation.py +0 -364
- tests/unit/test_child_workflows.py +0 -680
- tests/unit/test_continue_as_new.py +0 -441
- tests/unit/test_event_limits.py +0 -316
- tests/unit/test_executor.py +0 -320
- tests/unit/test_fault_tolerance.py +0 -334
- tests/unit/test_hooks.py +0 -495
- tests/unit/test_registry.py +0 -261
- tests/unit/test_replay.py +0 -420
- tests/unit/test_schedule_schemas.py +0 -285
- tests/unit/test_schedule_utils.py +0 -286
- tests/unit/test_scheduled_workflow.py +0 -274
- tests/unit/test_step.py +0 -353
- tests/unit/test_workflow.py +0 -243
- {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.10.dist-info}/WHEEL +0 -0
- {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.10.dist-info}/entry_points.txt +0 -0
- {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.10.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Step Context - User-defined context accessible from steps during distributed execution.
|
|
3
|
+
|
|
4
|
+
StepContext provides a type-safe, immutable context that can be accessed from steps
|
|
5
|
+
running on remote Celery workers. Unlike WorkflowContext which is process-local,
|
|
6
|
+
StepContext is serialized and passed to workers.
|
|
7
|
+
|
|
8
|
+
Key design decisions:
|
|
9
|
+
- **Immutable in steps**: Steps can only read context, not mutate it. This prevents
|
|
10
|
+
race conditions when multiple steps execute in parallel.
|
|
11
|
+
- **Mutable in workflow**: Workflow code can update context via set_step_context().
|
|
12
|
+
Updates are recorded as CONTEXT_UPDATED events for deterministic replay.
|
|
13
|
+
- **User-extensible**: Users subclass StepContext to define their own typed fields.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
from pyworkflow.context import StepContext, get_step_context, set_step_context
|
|
17
|
+
|
|
18
|
+
# Define custom context
|
|
19
|
+
class OrderContext(StepContext):
|
|
20
|
+
workspace_id: str = ""
|
|
21
|
+
user_id: str = ""
|
|
22
|
+
order_id: str = ""
|
|
23
|
+
|
|
24
|
+
@workflow(context_class=OrderContext)
|
|
25
|
+
async def process_order(order_id: str, user_id: str):
|
|
26
|
+
# Initialize context in workflow
|
|
27
|
+
ctx = OrderContext(order_id=order_id, user_id=user_id)
|
|
28
|
+
await set_step_context(ctx) # Note: async call
|
|
29
|
+
|
|
30
|
+
# Update context (creates new immutable instance)
|
|
31
|
+
ctx = get_step_context()
|
|
32
|
+
ctx = ctx.with_updates(workspace_id="ws-123")
|
|
33
|
+
await set_step_context(ctx)
|
|
34
|
+
|
|
35
|
+
result = await validate_order()
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
@step
|
|
39
|
+
async def validate_order():
|
|
40
|
+
# Read-only access in steps
|
|
41
|
+
ctx = get_step_context()
|
|
42
|
+
print(f"Validating order {ctx.order_id}")
|
|
43
|
+
|
|
44
|
+
# This would raise RuntimeError - context is read-only in steps:
|
|
45
|
+
# set_step_context(ctx.with_updates(workspace_id="new"))
|
|
46
|
+
|
|
47
|
+
return {"valid": True}
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from contextvars import ContextVar, Token
|
|
51
|
+
from typing import Any, Self
|
|
52
|
+
|
|
53
|
+
from pydantic import BaseModel, ConfigDict
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class StepContext(BaseModel):
|
|
57
|
+
"""
|
|
58
|
+
Base class for user-defined step context.
|
|
59
|
+
|
|
60
|
+
StepContext is immutable (frozen) to prevent accidental mutation.
|
|
61
|
+
Use with_updates() to create a new context with modified values.
|
|
62
|
+
|
|
63
|
+
The context is automatically:
|
|
64
|
+
- Persisted to storage when set_step_context() is called in workflow code
|
|
65
|
+
- Loaded from storage when a step executes on a Celery worker
|
|
66
|
+
- Replayed from CONTEXT_UPDATED events during workflow resumption
|
|
67
|
+
|
|
68
|
+
Example:
|
|
69
|
+
class FlowContext(StepContext):
|
|
70
|
+
workspace_id: str = ""
|
|
71
|
+
user_id: str = ""
|
|
72
|
+
attachments: list[str] = []
|
|
73
|
+
|
|
74
|
+
@workflow(context_class=FlowContext)
|
|
75
|
+
async def my_workflow():
|
|
76
|
+
ctx = FlowContext(workspace_id="ws-123")
|
|
77
|
+
set_step_context(ctx)
|
|
78
|
+
...
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
82
|
+
|
|
83
|
+
def with_updates(self: Self, **kwargs: Any) -> Self:
|
|
84
|
+
"""
|
|
85
|
+
Create a new context with updated values.
|
|
86
|
+
|
|
87
|
+
Since StepContext is immutable, this creates a new instance
|
|
88
|
+
with the specified fields updated.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
**kwargs: Fields to update
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
New StepContext instance with updated values
|
|
95
|
+
|
|
96
|
+
Example:
|
|
97
|
+
ctx = ctx.with_updates(workspace_id="ws-456", user_id="user-789")
|
|
98
|
+
"""
|
|
99
|
+
return self.model_copy(update=kwargs)
|
|
100
|
+
|
|
101
|
+
def to_dict(self) -> dict[str, Any]:
|
|
102
|
+
"""
|
|
103
|
+
Serialize context to dictionary for storage.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Dictionary representation of the context
|
|
107
|
+
"""
|
|
108
|
+
return self.model_dump()
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def from_dict(cls, data: dict[str, Any]) -> Self:
|
|
112
|
+
"""
|
|
113
|
+
Deserialize context from storage.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
data: Dictionary representation of the context
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
StepContext instance
|
|
120
|
+
"""
|
|
121
|
+
return cls.model_validate(data)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# Context Variables
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
# Current step context (may be None if not set)
|
|
129
|
+
_step_context: ContextVar[StepContext | None] = ContextVar("step_context", default=None)
|
|
130
|
+
|
|
131
|
+
# Whether context is read-only (True when executing inside a step)
|
|
132
|
+
_step_context_readonly: ContextVar[bool] = ContextVar("step_context_readonly", default=False)
|
|
133
|
+
|
|
134
|
+
# The context class registered with the workflow (for deserialization)
|
|
135
|
+
_step_context_class: ContextVar[type[StepContext] | None] = ContextVar(
|
|
136
|
+
"step_context_class", default=None
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# Public API
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def get_step_context() -> StepContext:
|
|
146
|
+
"""
|
|
147
|
+
Get the current step context.
|
|
148
|
+
|
|
149
|
+
This function can be called from both workflow code and step code.
|
|
150
|
+
In step code, the context is read-only.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Current StepContext instance
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
RuntimeError: If no step context is available
|
|
157
|
+
|
|
158
|
+
Example:
|
|
159
|
+
@step
|
|
160
|
+
async def my_step():
|
|
161
|
+
ctx = get_step_context()
|
|
162
|
+
print(f"Working in workspace: {ctx.workspace_id}")
|
|
163
|
+
"""
|
|
164
|
+
ctx = _step_context.get()
|
|
165
|
+
if ctx is None:
|
|
166
|
+
raise RuntimeError(
|
|
167
|
+
"No step context available. "
|
|
168
|
+
"Ensure the workflow is decorated with @workflow(context_class=YourContext) "
|
|
169
|
+
"and set_step_context() was called."
|
|
170
|
+
)
|
|
171
|
+
return ctx
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def set_step_context(ctx: StepContext) -> None:
|
|
175
|
+
"""
|
|
176
|
+
Set the current step context and persist to storage.
|
|
177
|
+
|
|
178
|
+
This function can only be called from workflow code, not from within steps.
|
|
179
|
+
When called, the context is persisted to storage and a CONTEXT_UPDATED event
|
|
180
|
+
is recorded for deterministic replay.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
ctx: The StepContext instance to set
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
RuntimeError: If called from within a step (read-only mode)
|
|
187
|
+
TypeError: If ctx is not a StepContext instance
|
|
188
|
+
|
|
189
|
+
Example:
|
|
190
|
+
@workflow(context_class=OrderContext)
|
|
191
|
+
async def my_workflow():
|
|
192
|
+
ctx = OrderContext(order_id="123")
|
|
193
|
+
await set_step_context(ctx) # OK - in workflow code
|
|
194
|
+
|
|
195
|
+
await my_step() # Step cannot call set_step_context()
|
|
196
|
+
"""
|
|
197
|
+
if _step_context_readonly.get():
|
|
198
|
+
raise RuntimeError(
|
|
199
|
+
"Cannot modify step context within a step. "
|
|
200
|
+
"Context is read-only during step execution to prevent race conditions. "
|
|
201
|
+
"Return data from the step and update context in workflow code instead."
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if not isinstance(ctx, StepContext):
|
|
205
|
+
raise TypeError(f"Expected StepContext instance, got {type(ctx).__name__}")
|
|
206
|
+
|
|
207
|
+
# Set the context in the contextvar
|
|
208
|
+
_step_context.set(ctx)
|
|
209
|
+
|
|
210
|
+
# Persist to storage if we're in a durable workflow
|
|
211
|
+
from pyworkflow.context import get_context, has_context
|
|
212
|
+
|
|
213
|
+
if has_context():
|
|
214
|
+
workflow_ctx = get_context()
|
|
215
|
+
if workflow_ctx.is_durable and workflow_ctx.storage is not None:
|
|
216
|
+
from pyworkflow.engine.events import create_context_updated_event
|
|
217
|
+
|
|
218
|
+
# Record CONTEXT_UPDATED event for replay
|
|
219
|
+
event = create_context_updated_event(
|
|
220
|
+
run_id=workflow_ctx.run_id,
|
|
221
|
+
context_data=ctx.to_dict(),
|
|
222
|
+
)
|
|
223
|
+
await workflow_ctx.storage.record_event(event)
|
|
224
|
+
|
|
225
|
+
# Update the WorkflowRun.context field
|
|
226
|
+
await workflow_ctx.storage.update_run_context(workflow_ctx.run_id, ctx.to_dict())
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def has_step_context() -> bool:
|
|
230
|
+
"""
|
|
231
|
+
Check if step context is available.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
True if step context is set, False otherwise
|
|
235
|
+
"""
|
|
236
|
+
return _step_context.get() is not None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def get_step_context_class() -> type[StepContext] | None:
|
|
240
|
+
"""
|
|
241
|
+
Get the registered step context class for the current workflow.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
The StepContext subclass, or None if not registered
|
|
245
|
+
"""
|
|
246
|
+
return _step_context_class.get()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
# Internal API (for framework use)
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _set_step_context_internal(ctx: StepContext | None) -> Token[StepContext | None]:
|
|
255
|
+
"""
|
|
256
|
+
Internal: Set step context without readonly check.
|
|
257
|
+
|
|
258
|
+
Used by the framework when loading context on workers.
|
|
259
|
+
"""
|
|
260
|
+
return _step_context.set(ctx)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _reset_step_context(token: Token[StepContext | None]) -> None:
|
|
264
|
+
"""
|
|
265
|
+
Internal: Reset step context to previous value.
|
|
266
|
+
"""
|
|
267
|
+
_step_context.reset(token)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _set_step_context_readonly(readonly: bool) -> Token[bool]:
|
|
271
|
+
"""
|
|
272
|
+
Internal: Set readonly mode for step execution.
|
|
273
|
+
"""
|
|
274
|
+
return _step_context_readonly.set(readonly)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _reset_step_context_readonly(token: Token[bool]) -> None:
|
|
278
|
+
"""
|
|
279
|
+
Internal: Reset readonly mode.
|
|
280
|
+
"""
|
|
281
|
+
_step_context_readonly.reset(token)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _set_step_context_class(cls: type[StepContext] | None) -> Token[type[StepContext] | None]:
|
|
285
|
+
"""
|
|
286
|
+
Internal: Set the context class for deserialization.
|
|
287
|
+
"""
|
|
288
|
+
return _step_context_class.set(cls)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _reset_step_context_class(token: Token[type[StepContext] | None]) -> None:
|
|
292
|
+
"""
|
|
293
|
+
Internal: Reset context class.
|
|
294
|
+
"""
|
|
295
|
+
_step_context_class.reset(token)
|
pyworkflow/core/registry.py
CHANGED
|
@@ -22,6 +22,7 @@ class WorkflowMetadata:
|
|
|
22
22
|
max_duration: str | None = None
|
|
23
23
|
tags: list[str] | None = None
|
|
24
24
|
description: str | None = None # Docstring from the workflow function
|
|
25
|
+
context_class: type | None = None # StepContext subclass for step context access
|
|
25
26
|
|
|
26
27
|
def __post_init__(self) -> None:
|
|
27
28
|
if self.tags is None:
|
|
@@ -70,6 +71,7 @@ class WorkflowRegistry:
|
|
|
70
71
|
original_func: Callable[..., Any],
|
|
71
72
|
max_duration: str | None = None,
|
|
72
73
|
tags: list[str] | None = None,
|
|
74
|
+
context_class: type | None = None,
|
|
73
75
|
) -> None:
|
|
74
76
|
"""
|
|
75
77
|
Register a workflow.
|
|
@@ -80,6 +82,7 @@ class WorkflowRegistry:
|
|
|
80
82
|
original_func: Original unwrapped function
|
|
81
83
|
max_duration: Optional maximum duration
|
|
82
84
|
tags: Optional list of tags (max 3)
|
|
85
|
+
context_class: Optional StepContext subclass for step context access
|
|
83
86
|
"""
|
|
84
87
|
if name in self._workflows:
|
|
85
88
|
existing = self._workflows[name]
|
|
@@ -96,6 +99,7 @@ class WorkflowRegistry:
|
|
|
96
99
|
original_func=original_func,
|
|
97
100
|
max_duration=max_duration,
|
|
98
101
|
tags=tags or [],
|
|
102
|
+
context_class=context_class,
|
|
99
103
|
)
|
|
100
104
|
|
|
101
105
|
self._workflows[name] = workflow_meta
|
|
@@ -250,9 +254,10 @@ def register_workflow(
|
|
|
250
254
|
original_func: Callable[..., Any],
|
|
251
255
|
max_duration: str | None = None,
|
|
252
256
|
tags: list[str] | None = None,
|
|
257
|
+
context_class: type | None = None,
|
|
253
258
|
) -> None:
|
|
254
259
|
"""Register a workflow in the global registry."""
|
|
255
|
-
_registry.register_workflow(name, func, original_func, max_duration, tags)
|
|
260
|
+
_registry.register_workflow(name, func, original_func, max_duration, tags, context_class)
|
|
256
261
|
|
|
257
262
|
|
|
258
263
|
def get_workflow(name: str) -> WorkflowMetadata | None:
|
pyworkflow/core/step.py
CHANGED
|
@@ -128,6 +128,20 @@ def step(
|
|
|
128
128
|
|
|
129
129
|
# Check if step has already completed (replay)
|
|
130
130
|
if not ctx.should_execute_step(step_id):
|
|
131
|
+
# Check if step failed (for distributed step dispatch)
|
|
132
|
+
if ctx.has_step_failed(step_id):
|
|
133
|
+
error_info = ctx.get_step_failure(step_id)
|
|
134
|
+
logger.error(
|
|
135
|
+
f"Step {step_name} failed on remote worker",
|
|
136
|
+
run_id=ctx.run_id,
|
|
137
|
+
step_id=step_id,
|
|
138
|
+
error=error_info.get("error") if error_info else "Unknown error",
|
|
139
|
+
)
|
|
140
|
+
raise FatalError(
|
|
141
|
+
f"Step {step_name} failed: "
|
|
142
|
+
f"{error_info.get('error') if error_info else 'Unknown error'}"
|
|
143
|
+
)
|
|
144
|
+
|
|
131
145
|
logger.debug(
|
|
132
146
|
f"Step {step_name} already completed, using cached result",
|
|
133
147
|
run_id=ctx.run_id,
|
|
@@ -135,6 +149,22 @@ def step(
|
|
|
135
149
|
)
|
|
136
150
|
return ctx.get_step_result(step_id)
|
|
137
151
|
|
|
152
|
+
# ========== Distributed Step Dispatch ==========
|
|
153
|
+
# When running in a distributed runtime (e.g., Celery), dispatch steps
|
|
154
|
+
# to step workers instead of executing inline.
|
|
155
|
+
if ctx.runtime == "celery":
|
|
156
|
+
return await _dispatch_step_to_celery(
|
|
157
|
+
ctx=ctx,
|
|
158
|
+
func=func,
|
|
159
|
+
args=args,
|
|
160
|
+
kwargs=kwargs,
|
|
161
|
+
step_name=step_name,
|
|
162
|
+
step_id=step_id,
|
|
163
|
+
max_retries=max_retries,
|
|
164
|
+
retry_delay=retry_delay,
|
|
165
|
+
timeout=timeout,
|
|
166
|
+
)
|
|
167
|
+
|
|
138
168
|
# Check if we're resuming from a retry
|
|
139
169
|
retry_state = ctx.get_retry_state(step_id)
|
|
140
170
|
if retry_state:
|
|
@@ -492,3 +522,114 @@ def _generate_step_id(step_name: str, args: tuple, kwargs: dict) -> str:
|
|
|
492
522
|
hash_hex = hashlib.sha256(content.encode()).hexdigest()[:16]
|
|
493
523
|
|
|
494
524
|
return f"step_{step_name}_{hash_hex}"
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
async def _dispatch_step_to_celery(
|
|
528
|
+
ctx: Any, # WorkflowContext
|
|
529
|
+
func: Callable,
|
|
530
|
+
args: tuple,
|
|
531
|
+
kwargs: dict,
|
|
532
|
+
step_name: str,
|
|
533
|
+
step_id: str,
|
|
534
|
+
max_retries: int,
|
|
535
|
+
retry_delay: str | int | list[int],
|
|
536
|
+
timeout: int | None,
|
|
537
|
+
) -> Any:
|
|
538
|
+
"""
|
|
539
|
+
Dispatch step execution to Celery step worker.
|
|
540
|
+
|
|
541
|
+
Instead of executing the step inline, this function:
|
|
542
|
+
1. Records STEP_STARTED event
|
|
543
|
+
2. Dispatches the step to execute_step_task on the steps queue
|
|
544
|
+
3. Raises SuspensionSignal to pause the workflow
|
|
545
|
+
|
|
546
|
+
The step worker will:
|
|
547
|
+
1. Execute the step function
|
|
548
|
+
2. Record STEP_COMPLETED/STEP_FAILED event
|
|
549
|
+
3. Trigger workflow resumption
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
ctx: Workflow context
|
|
553
|
+
func: Step function to execute
|
|
554
|
+
args: Positional arguments
|
|
555
|
+
kwargs: Keyword arguments
|
|
556
|
+
step_name: Name of the step
|
|
557
|
+
step_id: Deterministic step ID
|
|
558
|
+
max_retries: Maximum retry attempts
|
|
559
|
+
retry_delay: Retry delay strategy
|
|
560
|
+
timeout: Optional timeout in seconds
|
|
561
|
+
|
|
562
|
+
Returns:
|
|
563
|
+
This function never returns normally - it always raises SuspensionSignal
|
|
564
|
+
|
|
565
|
+
Raises:
|
|
566
|
+
SuspensionSignal: To pause workflow while step executes on worker
|
|
567
|
+
"""
|
|
568
|
+
from pyworkflow.celery.tasks import execute_step_task
|
|
569
|
+
from pyworkflow.core.exceptions import SuspensionSignal
|
|
570
|
+
|
|
571
|
+
logger.info(
|
|
572
|
+
f"Dispatching step to Celery worker: {step_name}",
|
|
573
|
+
run_id=ctx.run_id,
|
|
574
|
+
step_id=step_id,
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
# Validate event limits before recording step event
|
|
578
|
+
await ctx.validate_event_limits()
|
|
579
|
+
|
|
580
|
+
# Record STEP_STARTED event
|
|
581
|
+
start_event = create_step_started_event(
|
|
582
|
+
run_id=ctx.run_id,
|
|
583
|
+
step_id=step_id,
|
|
584
|
+
step_name=step_name,
|
|
585
|
+
args=serialize_args(*args),
|
|
586
|
+
kwargs=serialize_kwargs(**kwargs),
|
|
587
|
+
attempt=1,
|
|
588
|
+
)
|
|
589
|
+
await ctx.storage.record_event(start_event)
|
|
590
|
+
|
|
591
|
+
# Serialize arguments for Celery transport
|
|
592
|
+
args_json = serialize_args(*args)
|
|
593
|
+
kwargs_json = serialize_kwargs(**kwargs)
|
|
594
|
+
|
|
595
|
+
# Get step context data if available
|
|
596
|
+
context_data = None
|
|
597
|
+
context_class_name = None
|
|
598
|
+
try:
|
|
599
|
+
from pyworkflow.context.step_context import get_step_context, has_step_context
|
|
600
|
+
|
|
601
|
+
if has_step_context():
|
|
602
|
+
step_ctx = get_step_context()
|
|
603
|
+
context_data = step_ctx.to_dict()
|
|
604
|
+
context_class_name = f"{step_ctx.__class__.__module__}.{step_ctx.__class__.__name__}"
|
|
605
|
+
except Exception:
|
|
606
|
+
pass # Step context not available
|
|
607
|
+
|
|
608
|
+
# Dispatch to Celery step queue
|
|
609
|
+
task_result = execute_step_task.delay(
|
|
610
|
+
step_name=step_name,
|
|
611
|
+
args_json=args_json,
|
|
612
|
+
kwargs_json=kwargs_json,
|
|
613
|
+
run_id=ctx.run_id,
|
|
614
|
+
step_id=step_id,
|
|
615
|
+
max_retries=max_retries,
|
|
616
|
+
storage_config=ctx.storage_config,
|
|
617
|
+
context_data=context_data,
|
|
618
|
+
context_class_name=context_class_name,
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
logger.info(
|
|
622
|
+
f"Step dispatched to Celery: {step_name}",
|
|
623
|
+
run_id=ctx.run_id,
|
|
624
|
+
step_id=step_id,
|
|
625
|
+
task_id=task_result.id,
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
# Raise suspension signal - workflow will pause until step completes
|
|
629
|
+
# The step worker will record STEP_COMPLETED and trigger resume
|
|
630
|
+
raise SuspensionSignal(
|
|
631
|
+
reason=f"step_dispatch:{step_id}",
|
|
632
|
+
step_id=step_id,
|
|
633
|
+
step_name=step_name,
|
|
634
|
+
task_id=task_result.id,
|
|
635
|
+
)
|
pyworkflow/core/workflow.py
CHANGED
|
@@ -33,6 +33,7 @@ def workflow(
|
|
|
33
33
|
tags: list[str] | None = None,
|
|
34
34
|
recover_on_worker_loss: bool | None = None,
|
|
35
35
|
max_recovery_attempts: int | None = None,
|
|
36
|
+
context_class: type | None = None,
|
|
36
37
|
) -> Callable:
|
|
37
38
|
"""
|
|
38
39
|
Decorator to mark async functions as workflows.
|
|
@@ -49,6 +50,9 @@ def workflow(
|
|
|
49
50
|
recover_on_worker_loss: Whether to auto-recover on worker failure
|
|
50
51
|
(None = True for durable, False for transient)
|
|
51
52
|
max_recovery_attempts: Max recovery attempts on worker failure (default: 3)
|
|
53
|
+
context_class: Optional StepContext subclass for step context access.
|
|
54
|
+
When provided, enables get_step_context() and set_step_context()
|
|
55
|
+
for passing context data to steps running on remote workers.
|
|
52
56
|
|
|
53
57
|
Returns:
|
|
54
58
|
Decorated workflow function
|
|
@@ -85,6 +89,18 @@ def workflow(
|
|
|
85
89
|
async def tagged_workflow():
|
|
86
90
|
result = await my_step()
|
|
87
91
|
return result
|
|
92
|
+
|
|
93
|
+
Example (with step context):
|
|
94
|
+
class OrderContext(StepContext):
|
|
95
|
+
workspace_id: str = ""
|
|
96
|
+
user_id: str = ""
|
|
97
|
+
|
|
98
|
+
@workflow(context_class=OrderContext)
|
|
99
|
+
async def workflow_with_context():
|
|
100
|
+
ctx = OrderContext(workspace_id="ws-123")
|
|
101
|
+
set_step_context(ctx) # Persisted and available in steps
|
|
102
|
+
result = await my_step() # Step can call get_step_context()
|
|
103
|
+
return result
|
|
88
104
|
"""
|
|
89
105
|
|
|
90
106
|
# Validate tags
|
|
@@ -110,6 +126,7 @@ def workflow(
|
|
|
110
126
|
original_func=func,
|
|
111
127
|
max_duration=max_duration,
|
|
112
128
|
tags=validated_tags,
|
|
129
|
+
context_class=context_class,
|
|
113
130
|
)
|
|
114
131
|
|
|
115
132
|
# Store metadata on wrapper
|
|
@@ -124,6 +141,7 @@ def workflow(
|
|
|
124
141
|
wrapper.__workflow_max_recovery_attempts__ = ( # type: ignore[attr-defined]
|
|
125
142
|
max_recovery_attempts # None = use config default
|
|
126
143
|
)
|
|
144
|
+
wrapper.__workflow_context_class__ = context_class # type: ignore[attr-defined]
|
|
127
145
|
|
|
128
146
|
return wrapper
|
|
129
147
|
|
|
@@ -140,6 +158,8 @@ async def execute_workflow_with_context(
|
|
|
140
158
|
event_log: list | None = None,
|
|
141
159
|
durable: bool = True,
|
|
142
160
|
cancellation_requested: bool = False,
|
|
161
|
+
runtime: str | None = None,
|
|
162
|
+
storage_config: dict | None = None,
|
|
143
163
|
) -> Any:
|
|
144
164
|
"""
|
|
145
165
|
Execute workflow function with proper context setup.
|
|
@@ -161,6 +181,8 @@ async def execute_workflow_with_context(
|
|
|
161
181
|
event_log: Optional existing event log for replay
|
|
162
182
|
durable: Whether this is a durable workflow
|
|
163
183
|
cancellation_requested: Whether cancellation was requested before execution
|
|
184
|
+
runtime: Runtime environment slug (e.g., "celery") for distributed step dispatch
|
|
185
|
+
storage_config: Storage configuration dict for distributed step workers
|
|
164
186
|
|
|
165
187
|
Returns:
|
|
166
188
|
Workflow result
|
|
@@ -182,6 +204,10 @@ async def execute_workflow_with_context(
|
|
|
182
204
|
durable=is_durable,
|
|
183
205
|
)
|
|
184
206
|
|
|
207
|
+
# Set runtime environment for distributed step dispatch
|
|
208
|
+
ctx._runtime = runtime
|
|
209
|
+
ctx._storage_config = storage_config
|
|
210
|
+
|
|
185
211
|
# Set cancellation state if requested before execution
|
|
186
212
|
if cancellation_requested:
|
|
187
213
|
ctx.request_cancellation(reason="Cancellation requested before execution")
|
|
@@ -189,6 +215,26 @@ async def execute_workflow_with_context(
|
|
|
189
215
|
# Set as current context using new API
|
|
190
216
|
token = set_context(ctx)
|
|
191
217
|
|
|
218
|
+
# Set up step context if workflow has a context_class
|
|
219
|
+
step_context_token = None
|
|
220
|
+
step_context_class_token = None
|
|
221
|
+
context_class = getattr(workflow_func, "__workflow_context_class__", None)
|
|
222
|
+
if context_class is not None:
|
|
223
|
+
from pyworkflow.context.step_context import (
|
|
224
|
+
_set_step_context_class,
|
|
225
|
+
_set_step_context_internal,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Store context class for deserialization
|
|
229
|
+
step_context_class_token = _set_step_context_class(context_class)
|
|
230
|
+
|
|
231
|
+
# Load existing context from storage (for resumption)
|
|
232
|
+
if is_durable and storage is not None:
|
|
233
|
+
context_data = await storage.get_run_context(run_id)
|
|
234
|
+
if context_data:
|
|
235
|
+
step_ctx = context_class.from_dict(context_data)
|
|
236
|
+
step_context_token = _set_step_context_internal(step_ctx)
|
|
237
|
+
|
|
192
238
|
try:
|
|
193
239
|
# Note: Event replay is handled by LocalContext in its constructor
|
|
194
240
|
# when event_log is provided
|
|
@@ -290,5 +336,15 @@ async def execute_workflow_with_context(
|
|
|
290
336
|
raise
|
|
291
337
|
|
|
292
338
|
finally:
|
|
339
|
+
# Clear step context if it was set
|
|
340
|
+
if step_context_token is not None:
|
|
341
|
+
from pyworkflow.context.step_context import _reset_step_context
|
|
342
|
+
|
|
343
|
+
_reset_step_context(step_context_token)
|
|
344
|
+
if step_context_class_token is not None:
|
|
345
|
+
from pyworkflow.context.step_context import _reset_step_context_class
|
|
346
|
+
|
|
347
|
+
_reset_step_context_class(step_context_class_token)
|
|
348
|
+
|
|
293
349
|
# Clear context using new API
|
|
294
350
|
reset_context(token)
|
pyworkflow/engine/events.py
CHANGED
|
@@ -45,6 +45,9 @@ class EventType(Enum):
|
|
|
45
45
|
# Cancellation events
|
|
46
46
|
CANCELLATION_REQUESTED = "cancellation.requested"
|
|
47
47
|
|
|
48
|
+
# Context events
|
|
49
|
+
CONTEXT_UPDATED = "context.updated"
|
|
50
|
+
|
|
48
51
|
# Child workflow events
|
|
49
52
|
CHILD_WORKFLOW_STARTED = "child_workflow.started"
|
|
50
53
|
CHILD_WORKFLOW_COMPLETED = "child_workflow.completed"
|
|
@@ -480,6 +483,33 @@ def create_step_cancelled_event(
|
|
|
480
483
|
)
|
|
481
484
|
|
|
482
485
|
|
|
486
|
+
def create_context_updated_event(
|
|
487
|
+
run_id: str,
|
|
488
|
+
context_data: dict[str, Any],
|
|
489
|
+
) -> Event:
|
|
490
|
+
"""
|
|
491
|
+
Create a context updated event.
|
|
492
|
+
|
|
493
|
+
This event is recorded when set_step_context() is called in workflow code.
|
|
494
|
+
It captures the full context state for deterministic replay.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
run_id: The workflow run ID
|
|
498
|
+
context_data: The serialized context data (from StepContext.to_dict())
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
Event: The context updated event
|
|
502
|
+
"""
|
|
503
|
+
return Event(
|
|
504
|
+
run_id=run_id,
|
|
505
|
+
type=EventType.CONTEXT_UPDATED,
|
|
506
|
+
data={
|
|
507
|
+
"context": context_data,
|
|
508
|
+
"updated_at": datetime.now(UTC).isoformat(),
|
|
509
|
+
},
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
|
|
483
513
|
# Child workflow event creation helpers
|
|
484
514
|
|
|
485
515
|
|