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.
Files changed (146) hide show
  1. pyworkflow/__init__.py +10 -1
  2. pyworkflow/celery/tasks.py +272 -24
  3. pyworkflow/cli/__init__.py +4 -1
  4. pyworkflow/cli/commands/runs.py +4 -4
  5. pyworkflow/cli/commands/setup.py +203 -4
  6. pyworkflow/cli/utils/config_generator.py +76 -3
  7. pyworkflow/cli/utils/docker_manager.py +232 -0
  8. pyworkflow/config.py +94 -17
  9. pyworkflow/context/__init__.py +13 -0
  10. pyworkflow/context/base.py +26 -0
  11. pyworkflow/context/local.py +80 -0
  12. pyworkflow/context/step_context.py +295 -0
  13. pyworkflow/core/registry.py +6 -1
  14. pyworkflow/core/step.py +141 -0
  15. pyworkflow/core/workflow.py +56 -0
  16. pyworkflow/engine/events.py +30 -0
  17. pyworkflow/engine/replay.py +39 -0
  18. pyworkflow/primitives/child_workflow.py +1 -1
  19. pyworkflow/runtime/local.py +1 -1
  20. pyworkflow/storage/__init__.py +14 -0
  21. pyworkflow/storage/base.py +35 -0
  22. pyworkflow/storage/cassandra.py +1747 -0
  23. pyworkflow/storage/config.py +69 -0
  24. pyworkflow/storage/dynamodb.py +31 -2
  25. pyworkflow/storage/file.py +28 -0
  26. pyworkflow/storage/memory.py +18 -0
  27. pyworkflow/storage/mysql.py +1159 -0
  28. pyworkflow/storage/postgres.py +27 -2
  29. pyworkflow/storage/schemas.py +4 -3
  30. pyworkflow/storage/sqlite.py +25 -2
  31. {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.10.dist-info}/METADATA +7 -4
  32. pyworkflow_engine-0.1.10.dist-info/RECORD +91 -0
  33. pyworkflow_engine-0.1.10.dist-info/top_level.txt +1 -0
  34. dashboard/backend/app/__init__.py +0 -1
  35. dashboard/backend/app/config.py +0 -32
  36. dashboard/backend/app/controllers/__init__.py +0 -6
  37. dashboard/backend/app/controllers/run_controller.py +0 -86
  38. dashboard/backend/app/controllers/workflow_controller.py +0 -33
  39. dashboard/backend/app/dependencies/__init__.py +0 -5
  40. dashboard/backend/app/dependencies/storage.py +0 -50
  41. dashboard/backend/app/repositories/__init__.py +0 -6
  42. dashboard/backend/app/repositories/run_repository.py +0 -80
  43. dashboard/backend/app/repositories/workflow_repository.py +0 -27
  44. dashboard/backend/app/rest/__init__.py +0 -8
  45. dashboard/backend/app/rest/v1/__init__.py +0 -12
  46. dashboard/backend/app/rest/v1/health.py +0 -33
  47. dashboard/backend/app/rest/v1/runs.py +0 -133
  48. dashboard/backend/app/rest/v1/workflows.py +0 -41
  49. dashboard/backend/app/schemas/__init__.py +0 -23
  50. dashboard/backend/app/schemas/common.py +0 -16
  51. dashboard/backend/app/schemas/event.py +0 -24
  52. dashboard/backend/app/schemas/hook.py +0 -25
  53. dashboard/backend/app/schemas/run.py +0 -54
  54. dashboard/backend/app/schemas/step.py +0 -28
  55. dashboard/backend/app/schemas/workflow.py +0 -31
  56. dashboard/backend/app/server.py +0 -87
  57. dashboard/backend/app/services/__init__.py +0 -6
  58. dashboard/backend/app/services/run_service.py +0 -240
  59. dashboard/backend/app/services/workflow_service.py +0 -155
  60. dashboard/backend/main.py +0 -18
  61. docs/concepts/cancellation.mdx +0 -362
  62. docs/concepts/continue-as-new.mdx +0 -434
  63. docs/concepts/events.mdx +0 -266
  64. docs/concepts/fault-tolerance.mdx +0 -370
  65. docs/concepts/hooks.mdx +0 -552
  66. docs/concepts/limitations.mdx +0 -167
  67. docs/concepts/schedules.mdx +0 -775
  68. docs/concepts/sleep.mdx +0 -312
  69. docs/concepts/steps.mdx +0 -301
  70. docs/concepts/workflows.mdx +0 -255
  71. docs/guides/cli.mdx +0 -942
  72. docs/guides/configuration.mdx +0 -560
  73. docs/introduction.mdx +0 -155
  74. docs/quickstart.mdx +0 -279
  75. examples/__init__.py +0 -1
  76. examples/celery/__init__.py +0 -1
  77. examples/celery/durable/docker-compose.yml +0 -55
  78. examples/celery/durable/pyworkflow.config.yaml +0 -12
  79. examples/celery/durable/workflows/__init__.py +0 -122
  80. examples/celery/durable/workflows/basic.py +0 -87
  81. examples/celery/durable/workflows/batch_processing.py +0 -102
  82. examples/celery/durable/workflows/cancellation.py +0 -273
  83. examples/celery/durable/workflows/child_workflow_patterns.py +0 -240
  84. examples/celery/durable/workflows/child_workflows.py +0 -202
  85. examples/celery/durable/workflows/continue_as_new.py +0 -260
  86. examples/celery/durable/workflows/fault_tolerance.py +0 -210
  87. examples/celery/durable/workflows/hooks.py +0 -211
  88. examples/celery/durable/workflows/idempotency.py +0 -112
  89. examples/celery/durable/workflows/long_running.py +0 -99
  90. examples/celery/durable/workflows/retries.py +0 -101
  91. examples/celery/durable/workflows/schedules.py +0 -209
  92. examples/celery/transient/01_basic_workflow.py +0 -91
  93. examples/celery/transient/02_fault_tolerance.py +0 -257
  94. examples/celery/transient/__init__.py +0 -20
  95. examples/celery/transient/pyworkflow.config.yaml +0 -25
  96. examples/local/__init__.py +0 -1
  97. examples/local/durable/01_basic_workflow.py +0 -94
  98. examples/local/durable/02_file_storage.py +0 -132
  99. examples/local/durable/03_retries.py +0 -169
  100. examples/local/durable/04_long_running.py +0 -119
  101. examples/local/durable/05_event_log.py +0 -145
  102. examples/local/durable/06_idempotency.py +0 -148
  103. examples/local/durable/07_hooks.py +0 -334
  104. examples/local/durable/08_cancellation.py +0 -233
  105. examples/local/durable/09_child_workflows.py +0 -198
  106. examples/local/durable/10_child_workflow_patterns.py +0 -265
  107. examples/local/durable/11_continue_as_new.py +0 -249
  108. examples/local/durable/12_schedules.py +0 -198
  109. examples/local/durable/__init__.py +0 -1
  110. examples/local/transient/01_quick_tasks.py +0 -87
  111. examples/local/transient/02_retries.py +0 -130
  112. examples/local/transient/03_sleep.py +0 -141
  113. examples/local/transient/__init__.py +0 -1
  114. pyworkflow_engine-0.1.7.dist-info/RECORD +0 -196
  115. pyworkflow_engine-0.1.7.dist-info/top_level.txt +0 -5
  116. tests/examples/__init__.py +0 -0
  117. tests/integration/__init__.py +0 -0
  118. tests/integration/test_cancellation.py +0 -330
  119. tests/integration/test_child_workflows.py +0 -439
  120. tests/integration/test_continue_as_new.py +0 -428
  121. tests/integration/test_dynamodb_storage.py +0 -1146
  122. tests/integration/test_fault_tolerance.py +0 -369
  123. tests/integration/test_schedule_storage.py +0 -484
  124. tests/unit/__init__.py +0 -0
  125. tests/unit/backends/__init__.py +0 -1
  126. tests/unit/backends/test_dynamodb_storage.py +0 -1554
  127. tests/unit/backends/test_postgres_storage.py +0 -1281
  128. tests/unit/backends/test_sqlite_storage.py +0 -1460
  129. tests/unit/conftest.py +0 -41
  130. tests/unit/test_cancellation.py +0 -364
  131. tests/unit/test_child_workflows.py +0 -680
  132. tests/unit/test_continue_as_new.py +0 -441
  133. tests/unit/test_event_limits.py +0 -316
  134. tests/unit/test_executor.py +0 -320
  135. tests/unit/test_fault_tolerance.py +0 -334
  136. tests/unit/test_hooks.py +0 -495
  137. tests/unit/test_registry.py +0 -261
  138. tests/unit/test_replay.py +0 -420
  139. tests/unit/test_schedule_schemas.py +0 -285
  140. tests/unit/test_schedule_utils.py +0 -286
  141. tests/unit/test_scheduled_workflow.py +0 -274
  142. tests/unit/test_step.py +0 -353
  143. tests/unit/test_workflow.py +0 -243
  144. {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.10.dist-info}/WHEEL +0 -0
  145. {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.10.dist-info}/entry_points.txt +0 -0
  146. {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)
@@ -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
+ )
@@ -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)
@@ -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