pyworkflow-engine 0.1.7__py3-none-any.whl → 0.1.9__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 (145) 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/context/__init__.py +13 -0
  9. pyworkflow/context/base.py +26 -0
  10. pyworkflow/context/local.py +80 -0
  11. pyworkflow/context/step_context.py +295 -0
  12. pyworkflow/core/registry.py +6 -1
  13. pyworkflow/core/step.py +141 -0
  14. pyworkflow/core/workflow.py +56 -0
  15. pyworkflow/engine/events.py +30 -0
  16. pyworkflow/engine/replay.py +39 -0
  17. pyworkflow/primitives/child_workflow.py +1 -1
  18. pyworkflow/runtime/local.py +1 -1
  19. pyworkflow/storage/__init__.py +14 -0
  20. pyworkflow/storage/base.py +35 -0
  21. pyworkflow/storage/cassandra.py +1747 -0
  22. pyworkflow/storage/config.py +69 -0
  23. pyworkflow/storage/dynamodb.py +31 -2
  24. pyworkflow/storage/file.py +28 -0
  25. pyworkflow/storage/memory.py +18 -0
  26. pyworkflow/storage/mysql.py +1159 -0
  27. pyworkflow/storage/postgres.py +27 -2
  28. pyworkflow/storage/schemas.py +4 -3
  29. pyworkflow/storage/sqlite.py +25 -2
  30. {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.9.dist-info}/METADATA +7 -4
  31. pyworkflow_engine-0.1.9.dist-info/RECORD +91 -0
  32. pyworkflow_engine-0.1.9.dist-info/top_level.txt +1 -0
  33. dashboard/backend/app/__init__.py +0 -1
  34. dashboard/backend/app/config.py +0 -32
  35. dashboard/backend/app/controllers/__init__.py +0 -6
  36. dashboard/backend/app/controllers/run_controller.py +0 -86
  37. dashboard/backend/app/controllers/workflow_controller.py +0 -33
  38. dashboard/backend/app/dependencies/__init__.py +0 -5
  39. dashboard/backend/app/dependencies/storage.py +0 -50
  40. dashboard/backend/app/repositories/__init__.py +0 -6
  41. dashboard/backend/app/repositories/run_repository.py +0 -80
  42. dashboard/backend/app/repositories/workflow_repository.py +0 -27
  43. dashboard/backend/app/rest/__init__.py +0 -8
  44. dashboard/backend/app/rest/v1/__init__.py +0 -12
  45. dashboard/backend/app/rest/v1/health.py +0 -33
  46. dashboard/backend/app/rest/v1/runs.py +0 -133
  47. dashboard/backend/app/rest/v1/workflows.py +0 -41
  48. dashboard/backend/app/schemas/__init__.py +0 -23
  49. dashboard/backend/app/schemas/common.py +0 -16
  50. dashboard/backend/app/schemas/event.py +0 -24
  51. dashboard/backend/app/schemas/hook.py +0 -25
  52. dashboard/backend/app/schemas/run.py +0 -54
  53. dashboard/backend/app/schemas/step.py +0 -28
  54. dashboard/backend/app/schemas/workflow.py +0 -31
  55. dashboard/backend/app/server.py +0 -87
  56. dashboard/backend/app/services/__init__.py +0 -6
  57. dashboard/backend/app/services/run_service.py +0 -240
  58. dashboard/backend/app/services/workflow_service.py +0 -155
  59. dashboard/backend/main.py +0 -18
  60. docs/concepts/cancellation.mdx +0 -362
  61. docs/concepts/continue-as-new.mdx +0 -434
  62. docs/concepts/events.mdx +0 -266
  63. docs/concepts/fault-tolerance.mdx +0 -370
  64. docs/concepts/hooks.mdx +0 -552
  65. docs/concepts/limitations.mdx +0 -167
  66. docs/concepts/schedules.mdx +0 -775
  67. docs/concepts/sleep.mdx +0 -312
  68. docs/concepts/steps.mdx +0 -301
  69. docs/concepts/workflows.mdx +0 -255
  70. docs/guides/cli.mdx +0 -942
  71. docs/guides/configuration.mdx +0 -560
  72. docs/introduction.mdx +0 -155
  73. docs/quickstart.mdx +0 -279
  74. examples/__init__.py +0 -1
  75. examples/celery/__init__.py +0 -1
  76. examples/celery/durable/docker-compose.yml +0 -55
  77. examples/celery/durable/pyworkflow.config.yaml +0 -12
  78. examples/celery/durable/workflows/__init__.py +0 -122
  79. examples/celery/durable/workflows/basic.py +0 -87
  80. examples/celery/durable/workflows/batch_processing.py +0 -102
  81. examples/celery/durable/workflows/cancellation.py +0 -273
  82. examples/celery/durable/workflows/child_workflow_patterns.py +0 -240
  83. examples/celery/durable/workflows/child_workflows.py +0 -202
  84. examples/celery/durable/workflows/continue_as_new.py +0 -260
  85. examples/celery/durable/workflows/fault_tolerance.py +0 -210
  86. examples/celery/durable/workflows/hooks.py +0 -211
  87. examples/celery/durable/workflows/idempotency.py +0 -112
  88. examples/celery/durable/workflows/long_running.py +0 -99
  89. examples/celery/durable/workflows/retries.py +0 -101
  90. examples/celery/durable/workflows/schedules.py +0 -209
  91. examples/celery/transient/01_basic_workflow.py +0 -91
  92. examples/celery/transient/02_fault_tolerance.py +0 -257
  93. examples/celery/transient/__init__.py +0 -20
  94. examples/celery/transient/pyworkflow.config.yaml +0 -25
  95. examples/local/__init__.py +0 -1
  96. examples/local/durable/01_basic_workflow.py +0 -94
  97. examples/local/durable/02_file_storage.py +0 -132
  98. examples/local/durable/03_retries.py +0 -169
  99. examples/local/durable/04_long_running.py +0 -119
  100. examples/local/durable/05_event_log.py +0 -145
  101. examples/local/durable/06_idempotency.py +0 -148
  102. examples/local/durable/07_hooks.py +0 -334
  103. examples/local/durable/08_cancellation.py +0 -233
  104. examples/local/durable/09_child_workflows.py +0 -198
  105. examples/local/durable/10_child_workflow_patterns.py +0 -265
  106. examples/local/durable/11_continue_as_new.py +0 -249
  107. examples/local/durable/12_schedules.py +0 -198
  108. examples/local/durable/__init__.py +0 -1
  109. examples/local/transient/01_quick_tasks.py +0 -87
  110. examples/local/transient/02_retries.py +0 -130
  111. examples/local/transient/03_sleep.py +0 -141
  112. examples/local/transient/__init__.py +0 -1
  113. pyworkflow_engine-0.1.7.dist-info/RECORD +0 -196
  114. pyworkflow_engine-0.1.7.dist-info/top_level.txt +0 -5
  115. tests/examples/__init__.py +0 -0
  116. tests/integration/__init__.py +0 -0
  117. tests/integration/test_cancellation.py +0 -330
  118. tests/integration/test_child_workflows.py +0 -439
  119. tests/integration/test_continue_as_new.py +0 -428
  120. tests/integration/test_dynamodb_storage.py +0 -1146
  121. tests/integration/test_fault_tolerance.py +0 -369
  122. tests/integration/test_schedule_storage.py +0 -484
  123. tests/unit/__init__.py +0 -0
  124. tests/unit/backends/__init__.py +0 -1
  125. tests/unit/backends/test_dynamodb_storage.py +0 -1554
  126. tests/unit/backends/test_postgres_storage.py +0 -1281
  127. tests/unit/backends/test_sqlite_storage.py +0 -1460
  128. tests/unit/conftest.py +0 -41
  129. tests/unit/test_cancellation.py +0 -364
  130. tests/unit/test_child_workflows.py +0 -680
  131. tests/unit/test_continue_as_new.py +0 -441
  132. tests/unit/test_event_limits.py +0 -316
  133. tests/unit/test_executor.py +0 -320
  134. tests/unit/test_fault_tolerance.py +0 -334
  135. tests/unit/test_hooks.py +0 -495
  136. tests/unit/test_registry.py +0 -261
  137. tests/unit/test_replay.py +0 -420
  138. tests/unit/test_schedule_schemas.py +0 -285
  139. tests/unit/test_schedule_utils.py +0 -286
  140. tests/unit/test_scheduled_workflow.py +0 -274
  141. tests/unit/test_step.py +0 -353
  142. tests/unit/test_workflow.py +0 -243
  143. {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.9.dist-info}/WHEEL +0 -0
  144. {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.9.dist-info}/entry_points.txt +0 -0
  145. {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.9.dist-info}/licenses/LICENSE +0 -0
@@ -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
 
@@ -93,6 +93,9 @@ class EventReplayer:
93
93
  elif event.type == EventType.CANCELLATION_REQUESTED:
94
94
  await self._apply_cancellation_requested(ctx, event)
95
95
 
96
+ elif event.type == EventType.CONTEXT_UPDATED:
97
+ await self._apply_context_updated(ctx, event)
98
+
96
99
  # Other event types don't affect replay state
97
100
  # (workflow_started, step_started, step_failed, etc. are informational)
98
101
 
@@ -255,6 +258,42 @@ class EventReplayer:
255
258
  requested_by=requested_by,
256
259
  )
257
260
 
261
+ async def _apply_context_updated(self, ctx: LocalContext, event: Event) -> None:
262
+ """
263
+ Apply context_updated event - restore step context.
264
+
265
+ During replay, this restores the step context to its state at the time
266
+ the event was recorded. This ensures deterministic replay.
267
+ """
268
+ from pyworkflow.context.step_context import (
269
+ _set_step_context_internal,
270
+ get_step_context_class,
271
+ )
272
+
273
+ context_data = event.data.get("context", {})
274
+
275
+ if context_data:
276
+ # Get the registered context class
277
+ context_class = get_step_context_class()
278
+ if context_class is not None:
279
+ try:
280
+ step_ctx = context_class.from_dict(context_data)
281
+ _set_step_context_internal(step_ctx)
282
+ logger.debug(
283
+ "Restored step context from replay",
284
+ run_id=ctx.run_id,
285
+ )
286
+ except Exception as e:
287
+ logger.warning(
288
+ f"Failed to restore step context: {e}",
289
+ run_id=ctx.run_id,
290
+ )
291
+ else:
292
+ logger.debug(
293
+ "No context class registered, skipping context restoration",
294
+ run_id=ctx.run_id,
295
+ )
296
+
258
297
 
259
298
  # Singleton instance
260
299
  _replayer = EventReplayer()
@@ -346,7 +346,7 @@ async def _start_child_on_worker(
346
346
  parent_run_id=ctx.run_id,
347
347
  nesting_depth=child_depth,
348
348
  max_duration=workflow_meta.max_duration,
349
- metadata={}, # Run-level metadata
349
+ context={}, # Step context
350
350
  )
351
351
  await storage.create_run(child_run)
352
352
 
@@ -167,7 +167,7 @@ class LocalRuntime(Runtime):
167
167
  input_kwargs=serialize_kwargs(**kwargs),
168
168
  idempotency_key=idempotency_key,
169
169
  max_duration=max_duration,
170
- metadata=metadata or {},
170
+ context=metadata or {},
171
171
  )
172
172
  await storage.create_run(workflow_run)
173
173
 
@@ -35,6 +35,18 @@ try:
35
35
  except ImportError:
36
36
  DynamoDBStorageBackend = None # type: ignore
37
37
 
38
+ # Cassandra backend - optional import (requires cassandra-driver)
39
+ try:
40
+ from pyworkflow.storage.cassandra import CassandraStorageBackend
41
+ except ImportError:
42
+ CassandraStorageBackend = None # type: ignore
43
+
44
+ # MySQL backend - optional import (requires aiomysql)
45
+ try:
46
+ from pyworkflow.storage.mysql import MySQLStorageBackend
47
+ except ImportError:
48
+ MySQLStorageBackend = None # type: ignore
49
+
38
50
  __all__ = [
39
51
  "StorageBackend",
40
52
  "FileStorageBackend",
@@ -42,6 +54,8 @@ __all__ = [
42
54
  "SQLiteStorageBackend",
43
55
  "PostgresStorageBackend",
44
56
  "DynamoDBStorageBackend",
57
+ "CassandraStorageBackend",
58
+ "MySQLStorageBackend",
45
59
  "WorkflowRun",
46
60
  "StepExecution",
47
61
  "Hook",
@@ -109,6 +109,41 @@ class StorageBackend(ABC):
109
109
  """
110
110
  pass
111
111
 
112
+ @abstractmethod
113
+ async def update_run_context(
114
+ self,
115
+ run_id: str,
116
+ context: dict,
117
+ ) -> None:
118
+ """
119
+ Update the step context for a workflow run.
120
+
121
+ Called when set_step_context() is invoked in workflow code.
122
+ The context is stored and can be loaded by steps running on
123
+ remote workers.
124
+
125
+ Args:
126
+ run_id: Workflow run identifier
127
+ context: Context data as a dictionary (serialized StepContext)
128
+ """
129
+ pass
130
+
131
+ @abstractmethod
132
+ async def get_run_context(self, run_id: str) -> dict:
133
+ """
134
+ Get the current step context for a workflow run.
135
+
136
+ Called when a step starts execution on a remote worker to
137
+ load the context that was set by the workflow.
138
+
139
+ Args:
140
+ run_id: Workflow run identifier
141
+
142
+ Returns:
143
+ Context data as a dictionary, or empty dict if not set
144
+ """
145
+ pass
146
+
112
147
  @abstractmethod
113
148
  async def list_runs(
114
149
  self,