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
|
@@ -195,6 +195,131 @@ volumes:
|
|
|
195
195
|
return template
|
|
196
196
|
|
|
197
197
|
|
|
198
|
+
def generate_cassandra_docker_compose_content(
|
|
199
|
+
cassandra_host: str = "cassandra",
|
|
200
|
+
cassandra_port: int = 9042,
|
|
201
|
+
cassandra_keyspace: str = "pyworkflow",
|
|
202
|
+
cassandra_user: str | None = None,
|
|
203
|
+
cassandra_password: str | None = None,
|
|
204
|
+
) -> str:
|
|
205
|
+
"""
|
|
206
|
+
Generate docker-compose.yml content for Cassandra-based PyWorkflow services.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
cassandra_host: Cassandra host (container name for docker networking)
|
|
210
|
+
cassandra_port: Cassandra CQL native transport port
|
|
211
|
+
cassandra_keyspace: Cassandra keyspace name
|
|
212
|
+
cassandra_user: Optional Cassandra user (for authentication)
|
|
213
|
+
cassandra_password: Optional Cassandra password (for authentication)
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
docker-compose.yml content as string
|
|
217
|
+
|
|
218
|
+
Example:
|
|
219
|
+
>>> compose_content = generate_cassandra_docker_compose_content()
|
|
220
|
+
"""
|
|
221
|
+
# Build environment variables for dashboard
|
|
222
|
+
dashboard_env = f""" - DASHBOARD_STORAGE_TYPE=cassandra
|
|
223
|
+
- DASHBOARD_CASSANDRA_HOST={cassandra_host}
|
|
224
|
+
- DASHBOARD_CASSANDRA_PORT=9042
|
|
225
|
+
- DASHBOARD_CASSANDRA_KEYSPACE={cassandra_keyspace}"""
|
|
226
|
+
|
|
227
|
+
if cassandra_user and cassandra_password:
|
|
228
|
+
dashboard_env += f"""
|
|
229
|
+
- DASHBOARD_CASSANDRA_USER={cassandra_user}
|
|
230
|
+
- DASHBOARD_CASSANDRA_PASSWORD={cassandra_password}"""
|
|
231
|
+
|
|
232
|
+
# Cassandra environment for authentication
|
|
233
|
+
cassandra_env = ""
|
|
234
|
+
if cassandra_user and cassandra_password:
|
|
235
|
+
cassandra_env = f"""
|
|
236
|
+
- CASSANDRA_AUTHENTICATOR=PasswordAuthenticator
|
|
237
|
+
- CASSANDRA_AUTHORIZER=CassandraAuthorizer
|
|
238
|
+
- CASSANDRA_USER={cassandra_user}
|
|
239
|
+
- CASSANDRA_PASSWORD={cassandra_password}"""
|
|
240
|
+
|
|
241
|
+
template = f"""services:
|
|
242
|
+
cassandra:
|
|
243
|
+
image: cassandra:4.1
|
|
244
|
+
container_name: pyworkflow-cassandra
|
|
245
|
+
ports:
|
|
246
|
+
- "{cassandra_port}:9042"
|
|
247
|
+
environment:
|
|
248
|
+
- CASSANDRA_CLUSTER_NAME=pyworkflow-cluster
|
|
249
|
+
- CASSANDRA_DC=dc1
|
|
250
|
+
- CASSANDRA_RACK=rack1
|
|
251
|
+
- CASSANDRA_ENDPOINT_SNITCH=GossipingPropertyFileSnitch
|
|
252
|
+
- MAX_HEAP_SIZE=512M
|
|
253
|
+
- HEAP_NEWSIZE=128M{cassandra_env}
|
|
254
|
+
volumes:
|
|
255
|
+
- cassandra_data:/var/lib/cassandra
|
|
256
|
+
healthcheck:
|
|
257
|
+
test: ["CMD-SHELL", "cqlsh -e 'describe cluster' || exit 1"]
|
|
258
|
+
interval: 15s
|
|
259
|
+
timeout: 10s
|
|
260
|
+
retries: 10
|
|
261
|
+
start_period: 60s
|
|
262
|
+
restart: unless-stopped
|
|
263
|
+
|
|
264
|
+
redis:
|
|
265
|
+
image: redis:7-alpine
|
|
266
|
+
container_name: pyworkflow-redis
|
|
267
|
+
ports:
|
|
268
|
+
- "6379:6379"
|
|
269
|
+
volumes:
|
|
270
|
+
- redis_data:/data
|
|
271
|
+
healthcheck:
|
|
272
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
273
|
+
interval: 5s
|
|
274
|
+
timeout: 3s
|
|
275
|
+
retries: 5
|
|
276
|
+
restart: unless-stopped
|
|
277
|
+
|
|
278
|
+
dashboard-backend:
|
|
279
|
+
image: yashabro/pyworkflow-dashboard-backend:latest
|
|
280
|
+
container_name: pyworkflow-dashboard-backend
|
|
281
|
+
working_dir: /app/project
|
|
282
|
+
ports:
|
|
283
|
+
- "8585:8585"
|
|
284
|
+
environment:
|
|
285
|
+
- DASHBOARD_PYWORKFLOW_CONFIG_PATH=/app/project/pyworkflow.config.yaml
|
|
286
|
+
{dashboard_env}
|
|
287
|
+
- DASHBOARD_HOST=0.0.0.0
|
|
288
|
+
- DASHBOARD_PORT=8585
|
|
289
|
+
- DASHBOARD_CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
|
290
|
+
- PYWORKFLOW_CELERY_BROKER=redis://redis:6379/0
|
|
291
|
+
- PYWORKFLOW_CELERY_RESULT_BACKEND=redis://redis:6379/1
|
|
292
|
+
- PYTHONPATH=/app/project
|
|
293
|
+
volumes:
|
|
294
|
+
- .:/app/project:ro
|
|
295
|
+
depends_on:
|
|
296
|
+
cassandra:
|
|
297
|
+
condition: service_healthy
|
|
298
|
+
redis:
|
|
299
|
+
condition: service_healthy
|
|
300
|
+
restart: unless-stopped
|
|
301
|
+
|
|
302
|
+
dashboard-frontend:
|
|
303
|
+
image: yashabro/pyworkflow-dashboard-frontend:latest
|
|
304
|
+
container_name: pyworkflow-dashboard-frontend
|
|
305
|
+
ports:
|
|
306
|
+
- "5173:80"
|
|
307
|
+
environment:
|
|
308
|
+
- VITE_API_URL=http://localhost:8585
|
|
309
|
+
depends_on:
|
|
310
|
+
- dashboard-backend
|
|
311
|
+
restart: unless-stopped
|
|
312
|
+
|
|
313
|
+
volumes:
|
|
314
|
+
cassandra_data:
|
|
315
|
+
driver: local
|
|
316
|
+
redis_data:
|
|
317
|
+
driver: local
|
|
318
|
+
"""
|
|
319
|
+
|
|
320
|
+
return template
|
|
321
|
+
|
|
322
|
+
|
|
198
323
|
def generate_postgres_docker_compose_content(
|
|
199
324
|
postgres_host: str = "postgres",
|
|
200
325
|
postgres_port: int = 5432,
|
|
@@ -301,6 +426,113 @@ volumes:
|
|
|
301
426
|
return template
|
|
302
427
|
|
|
303
428
|
|
|
429
|
+
def generate_mysql_docker_compose_content(
|
|
430
|
+
mysql_host: str = "mysql",
|
|
431
|
+
mysql_port: int = 3306,
|
|
432
|
+
mysql_user: str = "pyworkflow",
|
|
433
|
+
mysql_password: str = "pyworkflow",
|
|
434
|
+
mysql_database: str = "pyworkflow",
|
|
435
|
+
) -> str:
|
|
436
|
+
"""
|
|
437
|
+
Generate docker-compose.yml content for MySQL-based PyWorkflow services.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
mysql_host: MySQL host (container name for docker networking)
|
|
441
|
+
mysql_port: MySQL port
|
|
442
|
+
mysql_user: MySQL user
|
|
443
|
+
mysql_password: MySQL password
|
|
444
|
+
mysql_database: MySQL database name
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
docker-compose.yml content as string
|
|
448
|
+
|
|
449
|
+
Example:
|
|
450
|
+
>>> compose_content = generate_mysql_docker_compose_content()
|
|
451
|
+
"""
|
|
452
|
+
template = f"""services:
|
|
453
|
+
mysql:
|
|
454
|
+
image: mysql:8.0
|
|
455
|
+
container_name: pyworkflow-mysql
|
|
456
|
+
ports:
|
|
457
|
+
- "{mysql_port}:3306"
|
|
458
|
+
environment:
|
|
459
|
+
- MYSQL_ROOT_PASSWORD={mysql_password}
|
|
460
|
+
- MYSQL_USER={mysql_user}
|
|
461
|
+
- MYSQL_PASSWORD={mysql_password}
|
|
462
|
+
- MYSQL_DATABASE={mysql_database}
|
|
463
|
+
volumes:
|
|
464
|
+
- mysql_data:/var/lib/mysql
|
|
465
|
+
healthcheck:
|
|
466
|
+
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "{mysql_user}", "-p{mysql_password}"]
|
|
467
|
+
interval: 5s
|
|
468
|
+
timeout: 3s
|
|
469
|
+
retries: 5
|
|
470
|
+
restart: unless-stopped
|
|
471
|
+
|
|
472
|
+
redis:
|
|
473
|
+
image: redis:7-alpine
|
|
474
|
+
container_name: pyworkflow-redis
|
|
475
|
+
ports:
|
|
476
|
+
- "6379:6379"
|
|
477
|
+
volumes:
|
|
478
|
+
- redis_data:/data
|
|
479
|
+
healthcheck:
|
|
480
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
481
|
+
interval: 5s
|
|
482
|
+
timeout: 3s
|
|
483
|
+
retries: 5
|
|
484
|
+
restart: unless-stopped
|
|
485
|
+
|
|
486
|
+
dashboard-backend:
|
|
487
|
+
image: yashabro/pyworkflow-dashboard-backend:latest
|
|
488
|
+
container_name: pyworkflow-dashboard-backend
|
|
489
|
+
working_dir: /app/project
|
|
490
|
+
ports:
|
|
491
|
+
- "8585:8585"
|
|
492
|
+
environment:
|
|
493
|
+
- DASHBOARD_PYWORKFLOW_CONFIG_PATH=/app/project/pyworkflow.config.yaml
|
|
494
|
+
- DASHBOARD_STORAGE_TYPE=mysql
|
|
495
|
+
- DASHBOARD_MYSQL_HOST={mysql_host}
|
|
496
|
+
- DASHBOARD_MYSQL_PORT=3306
|
|
497
|
+
- DASHBOARD_MYSQL_USER={mysql_user}
|
|
498
|
+
- DASHBOARD_MYSQL_PASSWORD={mysql_password}
|
|
499
|
+
- DASHBOARD_MYSQL_DATABASE={mysql_database}
|
|
500
|
+
- DASHBOARD_HOST=0.0.0.0
|
|
501
|
+
- DASHBOARD_PORT=8585
|
|
502
|
+
- DASHBOARD_CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
|
503
|
+
- PYWORKFLOW_CELERY_BROKER=redis://redis:6379/0
|
|
504
|
+
- PYWORKFLOW_CELERY_RESULT_BACKEND=redis://redis:6379/1
|
|
505
|
+
- PYTHONPATH=/app/project
|
|
506
|
+
volumes:
|
|
507
|
+
- .:/app/project:ro
|
|
508
|
+
depends_on:
|
|
509
|
+
mysql:
|
|
510
|
+
condition: service_healthy
|
|
511
|
+
redis:
|
|
512
|
+
condition: service_healthy
|
|
513
|
+
restart: unless-stopped
|
|
514
|
+
|
|
515
|
+
dashboard-frontend:
|
|
516
|
+
image: yashabro/pyworkflow-dashboard-frontend:latest
|
|
517
|
+
container_name: pyworkflow-dashboard-frontend
|
|
518
|
+
ports:
|
|
519
|
+
- "5173:80"
|
|
520
|
+
environment:
|
|
521
|
+
- VITE_API_URL=http://localhost:8585
|
|
522
|
+
depends_on:
|
|
523
|
+
- dashboard-backend
|
|
524
|
+
restart: unless-stopped
|
|
525
|
+
|
|
526
|
+
volumes:
|
|
527
|
+
mysql_data:
|
|
528
|
+
driver: local
|
|
529
|
+
redis_data:
|
|
530
|
+
driver: local
|
|
531
|
+
"""
|
|
532
|
+
|
|
533
|
+
return template
|
|
534
|
+
|
|
535
|
+
|
|
304
536
|
def write_docker_compose(content: str, path: Path) -> None:
|
|
305
537
|
"""
|
|
306
538
|
Write docker-compose.yml to file.
|
pyworkflow/context/__init__.py
CHANGED
|
@@ -41,6 +41,13 @@ from pyworkflow.context.base import (
|
|
|
41
41
|
)
|
|
42
42
|
from pyworkflow.context.local import LocalContext
|
|
43
43
|
from pyworkflow.context.mock import MockContext
|
|
44
|
+
from pyworkflow.context.step_context import (
|
|
45
|
+
StepContext,
|
|
46
|
+
get_step_context,
|
|
47
|
+
get_step_context_class,
|
|
48
|
+
has_step_context,
|
|
49
|
+
set_step_context,
|
|
50
|
+
)
|
|
44
51
|
|
|
45
52
|
__all__ = [
|
|
46
53
|
# Base context and helpers
|
|
@@ -49,6 +56,12 @@ __all__ = [
|
|
|
49
56
|
"has_context",
|
|
50
57
|
"set_context",
|
|
51
58
|
"reset_context",
|
|
59
|
+
# Step context for distributed execution
|
|
60
|
+
"StepContext",
|
|
61
|
+
"get_step_context",
|
|
62
|
+
"has_step_context",
|
|
63
|
+
"set_step_context",
|
|
64
|
+
"get_step_context_class",
|
|
52
65
|
# Context implementations
|
|
53
66
|
"LocalContext",
|
|
54
67
|
"MockContext",
|
pyworkflow/context/base.py
CHANGED
|
@@ -281,6 +281,32 @@ class WorkflowContext(ABC):
|
|
|
281
281
|
"""Get the storage backend."""
|
|
282
282
|
return None
|
|
283
283
|
|
|
284
|
+
@property
|
|
285
|
+
def runtime(self) -> str | None:
|
|
286
|
+
"""
|
|
287
|
+
Get the runtime environment slug.
|
|
288
|
+
|
|
289
|
+
Returns the runtime identifier (e.g., "celery", "temporal") or None
|
|
290
|
+
for local/inline execution. Used to determine step dispatch behavior.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
Runtime slug string or None for local execution
|
|
294
|
+
"""
|
|
295
|
+
return None # Default: local/inline execution
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def storage_config(self) -> dict[str, Any] | None:
|
|
299
|
+
"""Get storage configuration for distributed workers."""
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
def has_step_failed(self, step_id: str) -> bool:
|
|
303
|
+
"""Check if a step has a recorded failure."""
|
|
304
|
+
return False # Default: no failures
|
|
305
|
+
|
|
306
|
+
def get_step_failure(self, step_id: str) -> dict[str, Any] | None:
|
|
307
|
+
"""Get failure info for a step."""
|
|
308
|
+
return None # Default: no failure info
|
|
309
|
+
|
|
284
310
|
def should_execute_step(self, step_id: str) -> bool:
|
|
285
311
|
"""Check if step should be executed (not already completed)."""
|
|
286
312
|
return True # Default: always execute
|
pyworkflow/context/local.py
CHANGED
|
@@ -97,6 +97,13 @@ class LocalContext(WorkflowContext):
|
|
|
97
97
|
self._child_results: dict[str, dict[str, Any]] = {}
|
|
98
98
|
self._pending_children: dict[str, str] = {} # child_id -> child_run_id
|
|
99
99
|
|
|
100
|
+
# Runtime environment (e.g., "celery", "temporal", None for local)
|
|
101
|
+
self._runtime: str | None = None
|
|
102
|
+
self._storage_config: dict[str, Any] | None = None
|
|
103
|
+
|
|
104
|
+
# Step failure tracking (for handling failures during replay)
|
|
105
|
+
self._step_failures: dict[str, dict[str, Any]] = {}
|
|
106
|
+
|
|
100
107
|
# Replay state if resuming
|
|
101
108
|
if event_log:
|
|
102
109
|
self._is_replaying = True
|
|
@@ -134,6 +141,22 @@ class LocalContext(WorkflowContext):
|
|
|
134
141
|
"last_error": event.data.get("error", ""),
|
|
135
142
|
}
|
|
136
143
|
|
|
144
|
+
elif event.type == EventType.STEP_FAILED:
|
|
145
|
+
# Track step failures for distributed step dispatch
|
|
146
|
+
# If a step failed (not retrying), record it for replay detection
|
|
147
|
+
step_id = event.data.get("step_id")
|
|
148
|
+
is_retryable = event.data.get("is_retryable", True)
|
|
149
|
+
# Only track as a final failure if not retryable or if this is
|
|
150
|
+
# the last failure before a STEP_COMPLETED event
|
|
151
|
+
# For now, track all failures - the step decorator will check
|
|
152
|
+
# if there's also a STEP_COMPLETED event
|
|
153
|
+
if step_id and not is_retryable:
|
|
154
|
+
self._step_failures[step_id] = {
|
|
155
|
+
"error": event.data.get("error", "Unknown error"),
|
|
156
|
+
"error_type": event.data.get("error_type", "Exception"),
|
|
157
|
+
"is_retryable": is_retryable,
|
|
158
|
+
}
|
|
159
|
+
|
|
137
160
|
elif event.type == EventType.CANCELLATION_REQUESTED:
|
|
138
161
|
self._cancellation_requested = True
|
|
139
162
|
self._cancellation_reason = event.data.get("reason")
|
|
@@ -208,6 +231,28 @@ class LocalContext(WorkflowContext):
|
|
|
208
231
|
"""Set replay mode."""
|
|
209
232
|
self._is_replaying = value
|
|
210
233
|
|
|
234
|
+
@property
|
|
235
|
+
def runtime(self) -> str | None:
|
|
236
|
+
"""
|
|
237
|
+
Get the runtime environment slug.
|
|
238
|
+
|
|
239
|
+
Returns the runtime identifier (e.g., "celery", "temporal") or None
|
|
240
|
+
for local/inline execution. Used to determine step dispatch behavior.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Runtime slug string or None for local execution
|
|
244
|
+
"""
|
|
245
|
+
return self._runtime
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def storage_config(self) -> dict[str, Any] | None:
|
|
249
|
+
"""
|
|
250
|
+
Get storage configuration for distributed workers.
|
|
251
|
+
|
|
252
|
+
This is passed to step workers so they can connect to the same storage.
|
|
253
|
+
"""
|
|
254
|
+
return self._storage_config
|
|
255
|
+
|
|
211
256
|
# =========================================================================
|
|
212
257
|
# Step result caching (for @step decorator compatibility)
|
|
213
258
|
# =========================================================================
|
|
@@ -255,6 +300,41 @@ class LocalContext(WorkflowContext):
|
|
|
255
300
|
"""Clear retry state for a step."""
|
|
256
301
|
self._retry_states.pop(step_id, None)
|
|
257
302
|
|
|
303
|
+
# =========================================================================
|
|
304
|
+
# Step failure tracking (for distributed step dispatch)
|
|
305
|
+
# =========================================================================
|
|
306
|
+
|
|
307
|
+
def has_step_failed(self, step_id: str) -> bool:
|
|
308
|
+
"""
|
|
309
|
+
Check if a step has a recorded failure.
|
|
310
|
+
|
|
311
|
+
Used during replay to detect steps that failed on a remote worker.
|
|
312
|
+
"""
|
|
313
|
+
return step_id in self._step_failures
|
|
314
|
+
|
|
315
|
+
def get_step_failure(self, step_id: str) -> dict[str, Any] | None:
|
|
316
|
+
"""
|
|
317
|
+
Get step failure info.
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
Dict with 'error', 'error_type', 'is_retryable' or None
|
|
321
|
+
"""
|
|
322
|
+
return self._step_failures.get(step_id)
|
|
323
|
+
|
|
324
|
+
def record_step_failure(
|
|
325
|
+
self,
|
|
326
|
+
step_id: str,
|
|
327
|
+
error: str,
|
|
328
|
+
error_type: str,
|
|
329
|
+
is_retryable: bool,
|
|
330
|
+
) -> None:
|
|
331
|
+
"""Record a step failure for replay detection."""
|
|
332
|
+
self._step_failures[step_id] = {
|
|
333
|
+
"error": error,
|
|
334
|
+
"error_type": error_type,
|
|
335
|
+
"is_retryable": is_retryable,
|
|
336
|
+
}
|
|
337
|
+
|
|
258
338
|
# =========================================================================
|
|
259
339
|
# Sleep state management (for @step decorator and EventReplayer compatibility)
|
|
260
340
|
# =========================================================================
|
|
@@ -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)
|