pyworkflow-engine 0.1.21__py3-none-any.whl → 0.1.23__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 +1 -1
- pyworkflow/celery/app.py +18 -0
- pyworkflow/celery/tasks.py +148 -106
- pyworkflow/storage/base.py +36 -0
- pyworkflow/storage/cassandra.py +34 -0
- pyworkflow/storage/dynamodb.py +34 -0
- pyworkflow/storage/file.py +52 -0
- pyworkflow/storage/memory.py +37 -0
- pyworkflow/storage/migrations/__init__.py +15 -0
- pyworkflow/storage/migrations/base.py +299 -0
- pyworkflow/storage/mysql.py +186 -5
- pyworkflow/storage/postgres.py +194 -6
- pyworkflow/storage/sqlite.py +171 -5
- {pyworkflow_engine-0.1.21.dist-info → pyworkflow_engine-0.1.23.dist-info}/METADATA +1 -1
- {pyworkflow_engine-0.1.21.dist-info → pyworkflow_engine-0.1.23.dist-info}/RECORD +19 -17
- {pyworkflow_engine-0.1.21.dist-info → pyworkflow_engine-0.1.23.dist-info}/WHEEL +0 -0
- {pyworkflow_engine-0.1.21.dist-info → pyworkflow_engine-0.1.23.dist-info}/entry_points.txt +0 -0
- {pyworkflow_engine-0.1.21.dist-info → pyworkflow_engine-0.1.23.dist-info}/licenses/LICENSE +0 -0
- {pyworkflow_engine-0.1.21.dist-info → pyworkflow_engine-0.1.23.dist-info}/top_level.txt +0 -0
pyworkflow/__init__.py
CHANGED
pyworkflow/celery/app.py
CHANGED
|
@@ -151,6 +151,8 @@ def create_celery_app(
|
|
|
151
151
|
sentinel_master_name: str | None = None,
|
|
152
152
|
broker_transport_options: dict[str, Any] | None = None,
|
|
153
153
|
result_backend_transport_options: dict[str, Any] | None = None,
|
|
154
|
+
worker_max_memory_per_child: int | None = None,
|
|
155
|
+
worker_max_tasks_per_child: int | None = None,
|
|
154
156
|
) -> Celery:
|
|
155
157
|
"""
|
|
156
158
|
Create and configure a Celery application for PyWorkflow.
|
|
@@ -162,6 +164,8 @@ def create_celery_app(
|
|
|
162
164
|
sentinel_master_name: Redis Sentinel master name. Priority: parameter > PYWORKFLOW_CELERY_SENTINEL_MASTER env var > "mymaster"
|
|
163
165
|
broker_transport_options: Additional transport options for the broker (merged with defaults)
|
|
164
166
|
result_backend_transport_options: Additional transport options for the result backend (merged with defaults)
|
|
167
|
+
worker_max_memory_per_child: Max memory per worker child process (KB). Priority: parameter > PYWORKFLOW_WORKER_MAX_MEMORY env var > None (no limit)
|
|
168
|
+
worker_max_tasks_per_child: Max tasks per worker child before recycling. Priority: parameter > PYWORKFLOW_WORKER_MAX_TASKS env var > None (no limit)
|
|
165
169
|
|
|
166
170
|
Returns:
|
|
167
171
|
Configured Celery application
|
|
@@ -170,6 +174,8 @@ def create_celery_app(
|
|
|
170
174
|
PYWORKFLOW_CELERY_BROKER: Celery broker URL (used if broker_url param not provided)
|
|
171
175
|
PYWORKFLOW_CELERY_RESULT_BACKEND: Result backend URL (used if result_backend param not provided)
|
|
172
176
|
PYWORKFLOW_CELERY_SENTINEL_MASTER: Sentinel master name (used if sentinel_master_name param not provided)
|
|
177
|
+
PYWORKFLOW_WORKER_MAX_MEMORY: Max memory per worker child (KB) (used if worker_max_memory_per_child param not provided)
|
|
178
|
+
PYWORKFLOW_WORKER_MAX_TASKS: Max tasks per worker child (used if worker_max_tasks_per_child param not provided)
|
|
173
179
|
|
|
174
180
|
Examples:
|
|
175
181
|
# Default configuration (uses env vars if set, otherwise localhost Redis)
|
|
@@ -202,6 +208,14 @@ def create_celery_app(
|
|
|
202
208
|
or "redis://localhost:6379/1"
|
|
203
209
|
)
|
|
204
210
|
|
|
211
|
+
# Worker memory limits (KB) - prevents memory leaks from accumulating
|
|
212
|
+
# Priority: parameter > env var > None (no limit by default)
|
|
213
|
+
max_memory_env = os.getenv("PYWORKFLOW_WORKER_MAX_MEMORY")
|
|
214
|
+
max_memory = worker_max_memory_per_child or (int(max_memory_env) if max_memory_env else None)
|
|
215
|
+
|
|
216
|
+
max_tasks_env = os.getenv("PYWORKFLOW_WORKER_MAX_TASKS")
|
|
217
|
+
max_tasks = worker_max_tasks_per_child or (int(max_tasks_env) if max_tasks_env else None)
|
|
218
|
+
|
|
205
219
|
# Detect broker and backend types
|
|
206
220
|
is_sentinel_broker = is_sentinel_url(broker_url)
|
|
207
221
|
is_sentinel_backend = is_sentinel_url(result_backend)
|
|
@@ -310,6 +324,10 @@ def create_celery_app(
|
|
|
310
324
|
# Logging
|
|
311
325
|
worker_log_format="[%(asctime)s: %(levelname)s/%(processName)s] %(message)s",
|
|
312
326
|
worker_task_log_format="[%(asctime)s: %(levelname)s/%(processName)s] [%(task_name)s(%(task_id)s)] %(message)s",
|
|
327
|
+
# Worker memory management - prevents memory leaks from accumulating
|
|
328
|
+
# When set, workers are recycled after exceeding these limits
|
|
329
|
+
worker_max_memory_per_child=max_memory, # KB, None = no limit
|
|
330
|
+
worker_max_tasks_per_child=max_tasks, # None = no limit
|
|
313
331
|
)
|
|
314
332
|
|
|
315
333
|
# Configure singleton locking for Redis or Sentinel brokers
|
pyworkflow/celery/tasks.py
CHANGED
|
@@ -171,10 +171,9 @@ def execute_step_task(
|
|
|
171
171
|
raise FatalError(f"Step '{step_name}' not found in registry")
|
|
172
172
|
|
|
173
173
|
# Ignore processing step if already completed (idempotency)
|
|
174
|
-
|
|
175
|
-
already_completed =
|
|
176
|
-
|
|
177
|
-
for evt in events
|
|
174
|
+
# Use has_event() for efficient EXISTS check instead of loading all events
|
|
175
|
+
already_completed = run_async(
|
|
176
|
+
storage.has_event(run_id, EventType.STEP_COMPLETED.value, step_id=step_id)
|
|
178
177
|
)
|
|
179
178
|
if already_completed:
|
|
180
179
|
logger.warning(
|
|
@@ -379,9 +378,9 @@ async def _record_step_completion_and_resume(
|
|
|
379
378
|
|
|
380
379
|
Called by execute_step_task after successful step execution.
|
|
381
380
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
381
|
+
IMPORTANT: This function waits for WORKFLOW_SUSPENDED event before recording
|
|
382
|
+
STEP_COMPLETED to prevent race conditions where both events get the same
|
|
383
|
+
sequence number. The workflow must fully suspend before we record completion.
|
|
385
384
|
|
|
386
385
|
Idempotency: If STEP_COMPLETED already exists for this step_id, skip
|
|
387
386
|
recording and resume scheduling (another task already handled it).
|
|
@@ -397,10 +396,9 @@ async def _record_step_completion_and_resume(
|
|
|
397
396
|
await storage.connect()
|
|
398
397
|
|
|
399
398
|
# Idempotency check: skip if step already completed
|
|
400
|
-
|
|
401
|
-
already_completed =
|
|
402
|
-
|
|
403
|
-
for evt in events
|
|
399
|
+
# Use has_event() for efficient EXISTS check instead of loading all events
|
|
400
|
+
already_completed = await storage.has_event(
|
|
401
|
+
run_id, EventType.STEP_COMPLETED.value, step_id=step_id
|
|
404
402
|
)
|
|
405
403
|
if already_completed:
|
|
406
404
|
logger.info(
|
|
@@ -411,43 +409,64 @@ async def _record_step_completion_and_resume(
|
|
|
411
409
|
)
|
|
412
410
|
return
|
|
413
411
|
|
|
414
|
-
#
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
step_name=step_name,
|
|
420
|
-
)
|
|
421
|
-
await storage.record_event(completion_event)
|
|
412
|
+
# Wait for WORKFLOW_SUSPENDED event before recording STEP_COMPLETED
|
|
413
|
+
# This prevents race conditions where both events get the same sequence number
|
|
414
|
+
# Use has_event() for memory-efficient polling instead of loading all events
|
|
415
|
+
max_wait_attempts = 50 # 50 * 10ms = 500ms max wait
|
|
416
|
+
wait_interval = 0.01 # 10ms between checks
|
|
422
417
|
|
|
423
|
-
|
|
424
|
-
|
|
418
|
+
for _attempt in range(max_wait_attempts):
|
|
419
|
+
has_suspended = await storage.has_event(
|
|
420
|
+
run_id, EventType.WORKFLOW_SUSPENDED.value, step_id=step_id
|
|
421
|
+
)
|
|
422
|
+
if has_suspended:
|
|
423
|
+
break
|
|
425
424
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
has_suspended = any(evt.type == EventType.WORKFLOW_SUSPENDED for evt in events)
|
|
425
|
+
# Wait and check again
|
|
426
|
+
await asyncio.sleep(wait_interval)
|
|
429
427
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
run_id, datetime.now(UTC), storage_config, triggered_by="step_completed"
|
|
434
|
-
)
|
|
435
|
-
logger.info(
|
|
436
|
-
"Step completed and workflow resumption scheduled",
|
|
437
|
-
run_id=run_id,
|
|
438
|
-
step_id=step_id,
|
|
439
|
-
step_name=step_name,
|
|
428
|
+
# Also check if step was already completed by another task during wait
|
|
429
|
+
already_completed = await storage.has_event(
|
|
430
|
+
run_id, EventType.STEP_COMPLETED.value, step_id=step_id
|
|
440
431
|
)
|
|
432
|
+
if already_completed:
|
|
433
|
+
logger.info(
|
|
434
|
+
"Step already completed by another task during wait, skipping",
|
|
435
|
+
run_id=run_id,
|
|
436
|
+
step_id=step_id,
|
|
437
|
+
step_name=step_name,
|
|
438
|
+
)
|
|
439
|
+
return
|
|
441
440
|
else:
|
|
442
|
-
#
|
|
443
|
-
#
|
|
444
|
-
logger.
|
|
445
|
-
"
|
|
441
|
+
# Timeout waiting for suspension - log warning but proceed anyway
|
|
442
|
+
# This handles edge cases where the workflow completes without suspending
|
|
443
|
+
logger.warning(
|
|
444
|
+
"Timeout waiting for WORKFLOW_SUSPENDED event, proceeding with completion",
|
|
446
445
|
run_id=run_id,
|
|
447
446
|
step_id=step_id,
|
|
448
447
|
step_name=step_name,
|
|
449
448
|
)
|
|
450
449
|
|
|
450
|
+
# Record STEP_COMPLETED event
|
|
451
|
+
completion_event = create_step_completed_event(
|
|
452
|
+
run_id=run_id,
|
|
453
|
+
step_id=step_id,
|
|
454
|
+
result=serialize(result),
|
|
455
|
+
step_name=step_name,
|
|
456
|
+
)
|
|
457
|
+
await storage.record_event(completion_event)
|
|
458
|
+
|
|
459
|
+
# Schedule workflow resumption
|
|
460
|
+
schedule_workflow_resumption(
|
|
461
|
+
run_id, datetime.now(UTC), storage_config, triggered_by="step_completed"
|
|
462
|
+
)
|
|
463
|
+
logger.info(
|
|
464
|
+
"Step completed and workflow resumption scheduled",
|
|
465
|
+
run_id=run_id,
|
|
466
|
+
step_id=step_id,
|
|
467
|
+
step_name=step_name,
|
|
468
|
+
)
|
|
469
|
+
|
|
451
470
|
|
|
452
471
|
async def _record_step_failure_and_resume(
|
|
453
472
|
storage_config: dict[str, Any] | None,
|
|
@@ -464,9 +483,9 @@ async def _record_step_failure_and_resume(
|
|
|
464
483
|
Called by execute_step_task after step failure (when retries are exhausted).
|
|
465
484
|
The workflow will fail when it replays and sees the failure event.
|
|
466
485
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
486
|
+
IMPORTANT: This function waits for WORKFLOW_SUSPENDED event before recording
|
|
487
|
+
STEP_FAILED to prevent race conditions where both events get the same
|
|
488
|
+
sequence number. The workflow must fully suspend before we record failure.
|
|
470
489
|
|
|
471
490
|
Idempotency: If STEP_COMPLETED or terminal STEP_FAILED already exists
|
|
472
491
|
for this step_id, skip recording and resume scheduling.
|
|
@@ -481,17 +500,18 @@ async def _record_step_failure_and_resume(
|
|
|
481
500
|
await storage.connect()
|
|
482
501
|
|
|
483
502
|
# Idempotency check: skip if step already completed or terminally failed
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
503
|
+
# Use has_event() for efficient EXISTS check instead of loading all events
|
|
504
|
+
# Note: For STEP_FAILED with is_retryable check, we use has_event for STEP_COMPLETED
|
|
505
|
+
# and separately check STEP_FAILED (non-retryable failures are rare, so this is still efficient)
|
|
506
|
+
already_completed = await storage.has_event(
|
|
507
|
+
run_id, EventType.STEP_COMPLETED.value, step_id=step_id
|
|
508
|
+
)
|
|
509
|
+
# For terminal failures, we check separately (is_retryable=false in data)
|
|
510
|
+
# This is less common, so checking completion first is the fast path
|
|
511
|
+
already_failed_terminal = await storage.has_event(
|
|
512
|
+
run_id, EventType.STEP_FAILED.value, step_id=step_id, is_retryable="False"
|
|
493
513
|
)
|
|
494
|
-
if
|
|
514
|
+
if already_completed or already_failed_terminal:
|
|
495
515
|
logger.info(
|
|
496
516
|
"Step already completed/failed by another task, skipping",
|
|
497
517
|
run_id=run_id,
|
|
@@ -500,6 +520,46 @@ async def _record_step_failure_and_resume(
|
|
|
500
520
|
)
|
|
501
521
|
return
|
|
502
522
|
|
|
523
|
+
# Wait for WORKFLOW_SUSPENDED event before recording STEP_FAILED
|
|
524
|
+
# This prevents race conditions where both events get the same sequence number
|
|
525
|
+
# Use has_event() for memory-efficient polling instead of loading all events
|
|
526
|
+
max_wait_attempts = 50 # 50 * 10ms = 500ms max wait
|
|
527
|
+
wait_interval = 0.01 # 10ms between checks
|
|
528
|
+
|
|
529
|
+
for _attempt in range(max_wait_attempts):
|
|
530
|
+
has_suspended = await storage.has_event(
|
|
531
|
+
run_id, EventType.WORKFLOW_SUSPENDED.value, step_id=step_id
|
|
532
|
+
)
|
|
533
|
+
if has_suspended:
|
|
534
|
+
break
|
|
535
|
+
|
|
536
|
+
# Wait and check again
|
|
537
|
+
await asyncio.sleep(wait_interval)
|
|
538
|
+
|
|
539
|
+
# Also check if step was already handled by another task during wait
|
|
540
|
+
already_completed = await storage.has_event(
|
|
541
|
+
run_id, EventType.STEP_COMPLETED.value, step_id=step_id
|
|
542
|
+
)
|
|
543
|
+
already_failed_terminal = await storage.has_event(
|
|
544
|
+
run_id, EventType.STEP_FAILED.value, step_id=step_id, is_retryable="False"
|
|
545
|
+
)
|
|
546
|
+
if already_completed or already_failed_terminal:
|
|
547
|
+
logger.info(
|
|
548
|
+
"Step already completed/failed by another task during wait, skipping",
|
|
549
|
+
run_id=run_id,
|
|
550
|
+
step_id=step_id,
|
|
551
|
+
step_name=step_name,
|
|
552
|
+
)
|
|
553
|
+
return
|
|
554
|
+
else:
|
|
555
|
+
# Timeout waiting for suspension - log warning but proceed anyway
|
|
556
|
+
logger.warning(
|
|
557
|
+
"Timeout waiting for WORKFLOW_SUSPENDED event, proceeding with failure",
|
|
558
|
+
run_id=run_id,
|
|
559
|
+
step_id=step_id,
|
|
560
|
+
step_name=step_name,
|
|
561
|
+
)
|
|
562
|
+
|
|
503
563
|
# Record STEP_FAILED event
|
|
504
564
|
failure_event = create_step_failed_event(
|
|
505
565
|
run_id=run_id,
|
|
@@ -511,35 +571,17 @@ async def _record_step_failure_and_resume(
|
|
|
511
571
|
)
|
|
512
572
|
await storage.record_event(failure_event)
|
|
513
573
|
|
|
514
|
-
#
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
)
|
|
526
|
-
logger.info(
|
|
527
|
-
"Step failed and workflow resumption scheduled",
|
|
528
|
-
run_id=run_id,
|
|
529
|
-
step_id=step_id,
|
|
530
|
-
step_name=step_name,
|
|
531
|
-
error=error,
|
|
532
|
-
)
|
|
533
|
-
else:
|
|
534
|
-
# Workflow hasn't suspended yet - don't schedule resume
|
|
535
|
-
# The suspension handler will check for step failure and schedule resume
|
|
536
|
-
logger.info(
|
|
537
|
-
"Step failed but workflow not yet suspended, skipping resume scheduling",
|
|
538
|
-
run_id=run_id,
|
|
539
|
-
step_id=step_id,
|
|
540
|
-
step_name=step_name,
|
|
541
|
-
error=error,
|
|
542
|
-
)
|
|
574
|
+
# Schedule workflow resumption
|
|
575
|
+
schedule_workflow_resumption(
|
|
576
|
+
run_id, datetime.now(UTC), storage_config, triggered_by="step_failed"
|
|
577
|
+
)
|
|
578
|
+
logger.info(
|
|
579
|
+
"Step failed and workflow resumption scheduled",
|
|
580
|
+
run_id=run_id,
|
|
581
|
+
step_id=step_id,
|
|
582
|
+
step_name=step_name,
|
|
583
|
+
error=error,
|
|
584
|
+
)
|
|
543
585
|
|
|
544
586
|
|
|
545
587
|
async def _get_workflow_run_safe(
|
|
@@ -839,13 +881,13 @@ async def _execute_child_workflow_on_worker(
|
|
|
839
881
|
|
|
840
882
|
# For step dispatch suspensions, check if step already completed/failed
|
|
841
883
|
if step_id and e.reason.startswith("step_dispatch:"):
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
884
|
+
step_completed = await storage.has_event(
|
|
885
|
+
child_run_id, EventType.STEP_COMPLETED.value, step_id=step_id
|
|
886
|
+
)
|
|
887
|
+
step_failed = await storage.has_event(
|
|
888
|
+
child_run_id, EventType.STEP_FAILED.value, step_id=step_id
|
|
847
889
|
)
|
|
848
|
-
if
|
|
890
|
+
if step_completed or step_failed:
|
|
849
891
|
logger.info(
|
|
850
892
|
"Child step finished before suspension completed, scheduling resume",
|
|
851
893
|
child_run_id=child_run_id,
|
|
@@ -1092,8 +1134,8 @@ async def _handle_workflow_recovery(
|
|
|
1092
1134
|
return False
|
|
1093
1135
|
|
|
1094
1136
|
# Get last event sequence
|
|
1095
|
-
|
|
1096
|
-
last_event_sequence =
|
|
1137
|
+
latest_event = await storage.get_latest_event(run.run_id)
|
|
1138
|
+
last_event_sequence = latest_event.sequence if latest_event else None
|
|
1097
1139
|
|
|
1098
1140
|
# Record interruption event
|
|
1099
1141
|
interrupted_event = create_workflow_interrupted_event(
|
|
@@ -1235,13 +1277,13 @@ async def _recover_workflow_on_worker(
|
|
|
1235
1277
|
|
|
1236
1278
|
# For step dispatch suspensions, check if step already completed/failed
|
|
1237
1279
|
if step_id and e.reason.startswith("step_dispatch:"):
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
evt.type in (EventType.STEP_COMPLETED, EventType.STEP_FAILED)
|
|
1241
|
-
and evt.data.get("step_id") == step_id
|
|
1242
|
-
for evt in events
|
|
1280
|
+
step_completed = await storage.has_event(
|
|
1281
|
+
run_id, EventType.STEP_COMPLETED.value, step_id=step_id
|
|
1243
1282
|
)
|
|
1244
|
-
|
|
1283
|
+
step_failed = await storage.has_event(
|
|
1284
|
+
run_id, EventType.STEP_FAILED.value, step_id=step_id
|
|
1285
|
+
)
|
|
1286
|
+
if step_completed or step_failed:
|
|
1245
1287
|
logger.info(
|
|
1246
1288
|
"Step finished before recovery suspension completed, scheduling resume",
|
|
1247
1289
|
run_id=run_id,
|
|
@@ -1627,13 +1669,13 @@ async def _start_workflow_on_worker(
|
|
|
1627
1669
|
# For step dispatch suspensions, check if step already completed/failed (race condition)
|
|
1628
1670
|
# If so, schedule resume immediately
|
|
1629
1671
|
if step_id and e.reason.startswith("step_dispatch:"):
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1672
|
+
step_completed = await storage.has_event(
|
|
1673
|
+
run_id, EventType.STEP_COMPLETED.value, step_id=step_id
|
|
1674
|
+
)
|
|
1675
|
+
step_failed = await storage.has_event(
|
|
1676
|
+
run_id, EventType.STEP_FAILED.value, step_id=step_id
|
|
1635
1677
|
)
|
|
1636
|
-
if
|
|
1678
|
+
if step_completed or step_failed:
|
|
1637
1679
|
logger.info(
|
|
1638
1680
|
"Step finished before suspension completed, scheduling resume",
|
|
1639
1681
|
run_id=run_id,
|
|
@@ -2217,13 +2259,13 @@ async def _resume_workflow_on_worker(
|
|
|
2217
2259
|
|
|
2218
2260
|
# For step dispatch suspensions, check if step already completed/failed
|
|
2219
2261
|
if step_id and e.reason.startswith("step_dispatch:"):
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2262
|
+
step_completed = await storage.has_event(
|
|
2263
|
+
run_id, EventType.STEP_COMPLETED.value, step_id=step_id
|
|
2264
|
+
)
|
|
2265
|
+
step_failed = await storage.has_event(
|
|
2266
|
+
run_id, EventType.STEP_FAILED.value, step_id=step_id
|
|
2225
2267
|
)
|
|
2226
|
-
if
|
|
2268
|
+
if step_completed or step_failed:
|
|
2227
2269
|
logger.info(
|
|
2228
2270
|
"Step finished before resume suspension completed, scheduling resume",
|
|
2229
2271
|
run_id=run_id,
|
pyworkflow/storage/base.py
CHANGED
|
@@ -203,6 +203,42 @@ class StorageBackend(ABC):
|
|
|
203
203
|
"""
|
|
204
204
|
pass
|
|
205
205
|
|
|
206
|
+
@abstractmethod
|
|
207
|
+
async def has_event(
|
|
208
|
+
self,
|
|
209
|
+
run_id: str,
|
|
210
|
+
event_type: str,
|
|
211
|
+
**filters: str,
|
|
212
|
+
) -> bool:
|
|
213
|
+
"""
|
|
214
|
+
Check if an event exists matching the criteria.
|
|
215
|
+
|
|
216
|
+
This is a memory-efficient alternative to get_events() when you only
|
|
217
|
+
need to check for existence. Uses SQL EXISTS queries in SQL backends
|
|
218
|
+
for O(1) memory usage instead of loading all events.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
run_id: Workflow run identifier
|
|
222
|
+
event_type: Event type to check for (e.g., "step_completed")
|
|
223
|
+
**filters: Additional filters to match against event data fields.
|
|
224
|
+
For example, step_id="abc" will check data->>'step_id' = 'abc'
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
True if a matching event exists, False otherwise
|
|
228
|
+
|
|
229
|
+
Example:
|
|
230
|
+
# Check if step completed
|
|
231
|
+
exists = await storage.has_event(
|
|
232
|
+
run_id, "step_completed", step_id="step_123"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Check if workflow suspended for a specific step
|
|
236
|
+
exists = await storage.has_event(
|
|
237
|
+
run_id, "workflow_suspended", step_id="step_123"
|
|
238
|
+
)
|
|
239
|
+
"""
|
|
240
|
+
pass
|
|
241
|
+
|
|
206
242
|
@abstractmethod
|
|
207
243
|
async def get_latest_event(
|
|
208
244
|
self,
|
pyworkflow/storage/cassandra.py
CHANGED
|
@@ -896,6 +896,40 @@ class CassandraStorageBackend(StorageBackend):
|
|
|
896
896
|
|
|
897
897
|
return None
|
|
898
898
|
|
|
899
|
+
async def has_event(
|
|
900
|
+
self,
|
|
901
|
+
run_id: str,
|
|
902
|
+
event_type: str,
|
|
903
|
+
**filters: str,
|
|
904
|
+
) -> bool:
|
|
905
|
+
"""
|
|
906
|
+
Check if an event exists matching the criteria.
|
|
907
|
+
|
|
908
|
+
Loads events of the specified type and filters in Python for efficiency.
|
|
909
|
+
|
|
910
|
+
Args:
|
|
911
|
+
run_id: Workflow run identifier
|
|
912
|
+
event_type: Event type to check for
|
|
913
|
+
**filters: Additional filters for event data fields
|
|
914
|
+
|
|
915
|
+
Returns:
|
|
916
|
+
True if a matching event exists, False otherwise
|
|
917
|
+
"""
|
|
918
|
+
# Load only events of the specific type
|
|
919
|
+
events = await self.get_events(run_id, event_types=[event_type])
|
|
920
|
+
|
|
921
|
+
# Filter in Python
|
|
922
|
+
for event in events:
|
|
923
|
+
match = True
|
|
924
|
+
for key, value in filters.items():
|
|
925
|
+
if str(event.data.get(key)) != str(value):
|
|
926
|
+
match = False
|
|
927
|
+
break
|
|
928
|
+
if match:
|
|
929
|
+
return True
|
|
930
|
+
|
|
931
|
+
return False
|
|
932
|
+
|
|
899
933
|
# Step Operations
|
|
900
934
|
|
|
901
935
|
async def create_step(self, step: StepExecution) -> None:
|
pyworkflow/storage/dynamodb.py
CHANGED
|
@@ -588,6 +588,40 @@ class DynamoDBStorageBackend(StorageBackend):
|
|
|
588
588
|
|
|
589
589
|
return self._item_to_event(self._item_to_dict(items[0]))
|
|
590
590
|
|
|
591
|
+
async def has_event(
|
|
592
|
+
self,
|
|
593
|
+
run_id: str,
|
|
594
|
+
event_type: str,
|
|
595
|
+
**filters: str,
|
|
596
|
+
) -> bool:
|
|
597
|
+
"""
|
|
598
|
+
Check if an event exists matching the criteria.
|
|
599
|
+
|
|
600
|
+
Loads events of the specified type and filters in Python for efficiency.
|
|
601
|
+
|
|
602
|
+
Args:
|
|
603
|
+
run_id: Workflow run identifier
|
|
604
|
+
event_type: Event type to check for
|
|
605
|
+
**filters: Additional filters for event data fields
|
|
606
|
+
|
|
607
|
+
Returns:
|
|
608
|
+
True if a matching event exists, False otherwise
|
|
609
|
+
"""
|
|
610
|
+
# Load only events of the specific type
|
|
611
|
+
events = await self.get_events(run_id, event_types=[event_type])
|
|
612
|
+
|
|
613
|
+
# Filter in Python
|
|
614
|
+
for event in events:
|
|
615
|
+
match = True
|
|
616
|
+
for key, value in filters.items():
|
|
617
|
+
if str(event.data.get(key)) != str(value):
|
|
618
|
+
match = False
|
|
619
|
+
break
|
|
620
|
+
if match:
|
|
621
|
+
return True
|
|
622
|
+
|
|
623
|
+
return False
|
|
624
|
+
|
|
591
625
|
# Step Operations
|
|
592
626
|
|
|
593
627
|
async def create_step(self, step: StepExecution) -> None:
|
pyworkflow/storage/file.py
CHANGED
|
@@ -373,6 +373,58 @@ class FileStorageBackend(StorageBackend):
|
|
|
373
373
|
events = await self.get_events(run_id, event_types=[event_type] if event_type else None)
|
|
374
374
|
return events[-1] if events else None
|
|
375
375
|
|
|
376
|
+
async def has_event(
|
|
377
|
+
self,
|
|
378
|
+
run_id: str,
|
|
379
|
+
event_type: str,
|
|
380
|
+
**filters: str,
|
|
381
|
+
) -> bool:
|
|
382
|
+
"""
|
|
383
|
+
Check if an event exists using file-based iteration with early termination.
|
|
384
|
+
|
|
385
|
+
Reads the events file line by line and returns as soon as a match is found,
|
|
386
|
+
avoiding loading the entire event log into memory.
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
run_id: Workflow run identifier
|
|
390
|
+
event_type: Event type to check for
|
|
391
|
+
**filters: Additional filters for event data fields
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
True if a matching event exists, False otherwise
|
|
395
|
+
"""
|
|
396
|
+
events_file = self.events_dir / f"{run_id}.jsonl"
|
|
397
|
+
|
|
398
|
+
if not events_file.exists():
|
|
399
|
+
return False
|
|
400
|
+
|
|
401
|
+
def _check() -> bool:
|
|
402
|
+
with events_file.open("r") as f:
|
|
403
|
+
for line in f:
|
|
404
|
+
if not line.strip():
|
|
405
|
+
continue
|
|
406
|
+
|
|
407
|
+
data = json.loads(line)
|
|
408
|
+
|
|
409
|
+
# Check event type
|
|
410
|
+
if data["type"] != event_type:
|
|
411
|
+
continue
|
|
412
|
+
|
|
413
|
+
# Check all data filters
|
|
414
|
+
match = True
|
|
415
|
+
event_data = data.get("data", {})
|
|
416
|
+
for key, value in filters.items():
|
|
417
|
+
if str(event_data.get(key)) != str(value):
|
|
418
|
+
match = False
|
|
419
|
+
break
|
|
420
|
+
|
|
421
|
+
if match:
|
|
422
|
+
return True
|
|
423
|
+
|
|
424
|
+
return False
|
|
425
|
+
|
|
426
|
+
return await asyncio.to_thread(_check)
|
|
427
|
+
|
|
376
428
|
# Step Operations
|
|
377
429
|
|
|
378
430
|
async def create_step(self, step: StepExecution) -> None:
|
pyworkflow/storage/memory.py
CHANGED
|
@@ -250,6 +250,43 @@ class InMemoryStorageBackend(StorageBackend):
|
|
|
250
250
|
# Return event with highest sequence
|
|
251
251
|
return max(events, key=lambda e: e.sequence or 0)
|
|
252
252
|
|
|
253
|
+
async def has_event(
|
|
254
|
+
self,
|
|
255
|
+
run_id: str,
|
|
256
|
+
event_type: str,
|
|
257
|
+
**filters: str,
|
|
258
|
+
) -> bool:
|
|
259
|
+
"""
|
|
260
|
+
Check if an event exists by loading events of the specific type and filtering.
|
|
261
|
+
|
|
262
|
+
This approach:
|
|
263
|
+
1. Uses the event_types filter to load only events of the target type
|
|
264
|
+
2. Filters in Python on the loaded data
|
|
265
|
+
3. Significantly reduces memory vs loading ALL events
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
run_id: Workflow run identifier
|
|
269
|
+
event_type: Event type to check for
|
|
270
|
+
**filters: Additional filters for event data fields
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
True if a matching event exists, False otherwise
|
|
274
|
+
"""
|
|
275
|
+
# Load only events of the specific type
|
|
276
|
+
events = await self.get_events(run_id, event_types=[event_type])
|
|
277
|
+
|
|
278
|
+
# Filter in Python
|
|
279
|
+
for event in events:
|
|
280
|
+
match = True
|
|
281
|
+
for key, value in filters.items():
|
|
282
|
+
if str(event.data.get(key)) != str(value):
|
|
283
|
+
match = False
|
|
284
|
+
break
|
|
285
|
+
if match:
|
|
286
|
+
return True
|
|
287
|
+
|
|
288
|
+
return False
|
|
289
|
+
|
|
253
290
|
# Step Operations
|
|
254
291
|
|
|
255
292
|
async def create_step(self, step: StepExecution) -> None:
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Database schema migration framework for PyWorkflow storage backends.
|
|
3
|
+
|
|
4
|
+
This module provides a migration framework that allows storage backends to
|
|
5
|
+
evolve their schema over time while maintaining backward compatibility with
|
|
6
|
+
existing databases.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pyworkflow.storage.migrations.base import (
|
|
10
|
+
Migration,
|
|
11
|
+
MigrationRegistry,
|
|
12
|
+
MigrationRunner,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = ["Migration", "MigrationRegistry", "MigrationRunner"]
|