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.
- 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/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.9.dist-info}/METADATA +7 -4
- pyworkflow_engine-0.1.9.dist-info/RECORD +91 -0
- pyworkflow_engine-0.1.9.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.9.dist-info}/WHEEL +0 -0
- {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.9.dist-info}/entry_points.txt +0 -0
- {pyworkflow_engine-0.1.7.dist-info → pyworkflow_engine-0.1.9.dist-info}/licenses/LICENSE +0 -0
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
|
|
pyworkflow/engine/replay.py
CHANGED
|
@@ -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
|
-
|
|
349
|
+
context={}, # Step context
|
|
350
350
|
)
|
|
351
351
|
await storage.create_run(child_run)
|
|
352
352
|
|
pyworkflow/runtime/local.py
CHANGED
pyworkflow/storage/__init__.py
CHANGED
|
@@ -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",
|
pyworkflow/storage/base.py
CHANGED
|
@@ -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,
|