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 CHANGED
@@ -29,7 +29,7 @@ Quick Start:
29
29
  >>> run_id = await start(my_workflow, "Alice")
30
30
  """
31
31
 
32
- __version__ = "0.1.21"
32
+ __version__ = "0.1.23"
33
33
 
34
34
  # Configuration
35
35
  from pyworkflow.config import (
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
@@ -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
- events = run_async(storage.get_events(run_id))
175
- already_completed = any(
176
- evt.type == EventType.STEP_COMPLETED and evt.data.get("step_id") == step_id
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
- Only schedules resume if WORKFLOW_SUSPENDED event exists, indicating
383
- the workflow has fully suspended. This prevents race conditions where
384
- a step completes before the workflow has suspended.
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
- events = await storage.get_events(run_id)
401
- already_completed = any(
402
- evt.type == EventType.STEP_COMPLETED and evt.data.get("step_id") == step_id
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
- # Record STEP_COMPLETED event
415
- completion_event = create_step_completed_event(
416
- run_id=run_id,
417
- step_id=step_id,
418
- result=serialize(result),
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
- # Refresh events to include the one we just recorded
424
- events = await storage.get_events(run_id)
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
- # Check if workflow has suspended (WORKFLOW_SUSPENDED event exists)
427
- # Only schedule resume if workflow has properly suspended
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
- if has_suspended:
431
- # Workflow has suspended, safe to schedule resume
432
- schedule_workflow_resumption(
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
- # Workflow hasn't suspended yet - don't schedule resume
443
- # The suspension handler will check for step completion and schedule resume
444
- logger.info(
445
- "Step completed but workflow not yet suspended, skipping resume scheduling",
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
- Only schedules resume if WORKFLOW_SUSPENDED event exists, indicating
468
- the workflow has fully suspended. This prevents race conditions where
469
- a step fails before the workflow has suspended.
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
- events = await storage.get_events(run_id)
485
- already_handled = any(
486
- (evt.type == EventType.STEP_COMPLETED and evt.data.get("step_id") == step_id)
487
- or (
488
- evt.type == EventType.STEP_FAILED
489
- and evt.data.get("step_id") == step_id
490
- and not evt.data.get("is_retryable", True)
491
- )
492
- for evt in events
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 already_handled:
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
- # Refresh events to include the one we just recorded
515
- events = await storage.get_events(run_id)
516
-
517
- # Check if workflow has suspended (WORKFLOW_SUSPENDED event exists)
518
- # Only schedule resume if workflow has properly suspended
519
- has_suspended = any(evt.type == EventType.WORKFLOW_SUSPENDED for evt in events)
520
-
521
- if has_suspended:
522
- # Workflow has suspended, safe to schedule resume
523
- schedule_workflow_resumption(
524
- run_id, datetime.now(UTC), storage_config, triggered_by="step_failed"
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
- events = await storage.get_events(child_run_id)
843
- step_finished = any(
844
- evt.type in (EventType.STEP_COMPLETED, EventType.STEP_FAILED)
845
- and evt.data.get("step_id") == step_id
846
- for evt in events
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 step_finished:
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
- events = await storage.get_events(run.run_id)
1096
- last_event_sequence = max((e.sequence or 0 for e in events), default=0) if events else None
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
- events = await storage.get_events(run_id)
1239
- step_finished = any(
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
- if step_finished:
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
- events = await storage.get_events(run_id)
1631
- step_finished = any(
1632
- evt.type in (EventType.STEP_COMPLETED, EventType.STEP_FAILED)
1633
- and evt.data.get("step_id") == step_id
1634
- for evt in events
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 step_finished:
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
- events = await storage.get_events(run_id)
2221
- step_finished = any(
2222
- evt.type in (EventType.STEP_COMPLETED, EventType.STEP_FAILED)
2223
- and evt.data.get("step_id") == step_id
2224
- for evt in events
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 step_finished:
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,
@@ -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,
@@ -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:
@@ -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:
@@ -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:
@@ -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"]