pyworkflow-engine 0.1.36__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/PKG-INFO +1 -1
  2. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyproject.toml +1 -1
  3. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/__init__.py +39 -1
  4. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/celery/app.py +9 -2
  5. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/celery/scheduler.py +5 -3
  6. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/celery/tasks.py +195 -19
  7. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/hooks.py +15 -15
  8. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/runs.py +9 -9
  9. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/schedules.py +30 -24
  10. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/config.py +22 -3
  11. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/context/local.py +11 -0
  12. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/core/step.py +28 -9
  13. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/engine/events.py +160 -0
  14. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/engine/replay.py +49 -0
  15. pyworkflow_engine-0.2.0/pyworkflow/primitives/step_checkpoint.py +157 -0
  16. pyworkflow_engine-0.2.0/pyworkflow/primitives/step_hook.py +205 -0
  17. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/__init__.py +7 -0
  18. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/base.py +237 -0
  19. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/cassandra.py +125 -3
  20. pyworkflow_engine-0.2.0/pyworkflow/storage/citus.py +387 -0
  21. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/config.py +23 -2
  22. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/dynamodb.py +159 -39
  23. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/file.py +87 -0
  24. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/memory.py +197 -0
  25. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/mysql.py +71 -0
  26. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/postgres.py +76 -0
  27. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/sqlite.py +76 -3
  28. pyworkflow_engine-0.2.0/pyworkflow/streams/__init__.py +83 -0
  29. pyworkflow_engine-0.2.0/pyworkflow/streams/checkpoint.py +201 -0
  30. pyworkflow_engine-0.2.0/pyworkflow/streams/consumer.py +124 -0
  31. pyworkflow_engine-0.2.0/pyworkflow/streams/context.py +91 -0
  32. pyworkflow_engine-0.2.0/pyworkflow/streams/decorator.py +131 -0
  33. pyworkflow_engine-0.2.0/pyworkflow/streams/dispatcher.py +233 -0
  34. pyworkflow_engine-0.2.0/pyworkflow/streams/emit.py +157 -0
  35. pyworkflow_engine-0.2.0/pyworkflow/streams/registry.py +190 -0
  36. pyworkflow_engine-0.2.0/pyworkflow/streams/signal.py +70 -0
  37. pyworkflow_engine-0.2.0/pyworkflow/streams/step_context.py +79 -0
  38. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow_engine.egg-info/SOURCES.txt +22 -0
  39. pyworkflow_engine-0.2.0/tests/integration/test_stream_e2e.py +308 -0
  40. pyworkflow_engine-0.2.0/tests/unit/backends/test_citus_storage.py +286 -0
  41. pyworkflow_engine-0.2.0/tests/unit/test_emit.py +123 -0
  42. pyworkflow_engine-0.2.0/tests/unit/test_retention.py +245 -0
  43. pyworkflow_engine-0.2.0/tests/unit/test_signal.py +76 -0
  44. pyworkflow_engine-0.2.0/tests/unit/test_step_checkpoint.py +102 -0
  45. pyworkflow_engine-0.2.0/tests/unit/test_step_hook.py +202 -0
  46. pyworkflow_engine-0.2.0/tests/unit/test_stream_storage.py +188 -0
  47. pyworkflow_engine-0.2.0/tests/unit/test_stream_workflow.py +203 -0
  48. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/CLAUDE.md +0 -0
  49. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/DISTRIBUTED.md +0 -0
  50. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/LICENSE +0 -0
  51. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/MANIFEST.in +0 -0
  52. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/README.md +0 -0
  53. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/RELEASING.md +0 -0
  54. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/architecture.md +0 -0
  55. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/concepts/cancellation.mdx +0 -0
  56. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/concepts/continue-as-new.mdx +0 -0
  57. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/concepts/events.mdx +0 -0
  58. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/concepts/fault-tolerance.mdx +0 -0
  59. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/concepts/hooks.mdx +0 -0
  60. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/concepts/limitations.mdx +0 -0
  61. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/concepts/schedules.mdx +0 -0
  62. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/concepts/sleep.mdx +0 -0
  63. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/concepts/step-context.mdx +0 -0
  64. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/concepts/steps.mdx +0 -0
  65. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/concepts/workflows.mdx +0 -0
  66. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/conventions.md +0 -0
  67. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/guides/brokers.mdx +0 -0
  68. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/guides/cli.mdx +0 -0
  69. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/guides/configuration.mdx +0 -0
  70. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/harness-gaps.md +0 -0
  71. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/introduction.mdx +0 -0
  72. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/layers.md +0 -0
  73. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/docs/quickstart.mdx +0 -0
  74. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/__init__.py +0 -0
  75. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/__init__.py +0 -0
  76. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/docker-compose.yml +0 -0
  77. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/pyworkflow.config.yaml +0 -0
  78. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/__init__.py +0 -0
  79. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/basic.py +0 -0
  80. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/batch_processing.py +0 -0
  81. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/cancellation.py +0 -0
  82. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/child_workflow_from_step.py +0 -0
  83. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/child_workflow_patterns.py +0 -0
  84. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/child_workflows.py +0 -0
  85. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/continue_as_new.py +0 -0
  86. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/fault_tolerance.py +0 -0
  87. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/hooks.py +0 -0
  88. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/idempotency.py +0 -0
  89. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/long_running.py +0 -0
  90. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/retries.py +0 -0
  91. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/schedules.py +0 -0
  92. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/sleep_in_step.py +0 -0
  93. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/durable/workflows/step_context.py +0 -0
  94. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/transient/01_basic_workflow.py +0 -0
  95. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/transient/02_fault_tolerance.py +0 -0
  96. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/transient/__init__.py +0 -0
  97. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/celery/transient/pyworkflow.config.yaml +0 -0
  98. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/__init__.py +0 -0
  99. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/01_basic_workflow.py +0 -0
  100. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/02_file_storage.py +0 -0
  101. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/03_retries.py +0 -0
  102. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/04_long_running.py +0 -0
  103. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/05_event_log.py +0 -0
  104. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/06_idempotency.py +0 -0
  105. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/07_hooks.py +0 -0
  106. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/08_cancellation.py +0 -0
  107. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/09_child_workflows.py +0 -0
  108. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/10_child_workflow_patterns.py +0 -0
  109. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/11_continue_as_new.py +0 -0
  110. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/12_schedules.py +0 -0
  111. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/13_step_context.py +0 -0
  112. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/14_child_workflow_from_step.py +0 -0
  113. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/durable/__init__.py +0 -0
  114. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/transient/01_quick_tasks.py +0 -0
  115. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/transient/02_retries.py +0 -0
  116. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/transient/03_sleep.py +0 -0
  117. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/examples/local/transient/__init__.py +0 -0
  118. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/aws/__init__.py +0 -0
  119. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/aws/context.py +0 -0
  120. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/aws/handler.py +0 -0
  121. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/aws/testing.py +0 -0
  122. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/celery/__init__.py +0 -0
  123. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/celery/loop.py +0 -0
  124. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/celery/singleton.py +0 -0
  125. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/__init__.py +0 -0
  126. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/__main__.py +0 -0
  127. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/__init__.py +0 -0
  128. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/quickstart.py +0 -0
  129. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/scheduler.py +0 -0
  130. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/setup.py +0 -0
  131. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/worker.py +0 -0
  132. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/commands/workflows.py +0 -0
  133. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/output/__init__.py +0 -0
  134. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/output/formatters.py +0 -0
  135. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/output/styles.py +0 -0
  136. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/__init__.py +0 -0
  137. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/async_helpers.py +0 -0
  138. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/config.py +0 -0
  139. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/config_generator.py +0 -0
  140. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/discovery.py +0 -0
  141. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/docker_manager.py +0 -0
  142. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/interactive.py +0 -0
  143. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/cli/utils/storage.py +0 -0
  144. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/context/__init__.py +0 -0
  145. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/context/aws.py +0 -0
  146. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/context/base.py +0 -0
  147. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/context/mock.py +0 -0
  148. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/context/step_context.py +0 -0
  149. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/core/__init__.py +0 -0
  150. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/core/exceptions.py +0 -0
  151. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/core/registry.py +0 -0
  152. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/core/scheduled.py +0 -0
  153. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/core/validation.py +0 -0
  154. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/core/workflow.py +0 -0
  155. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/discovery.py +0 -0
  156. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/engine/__init__.py +0 -0
  157. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/engine/executor.py +0 -0
  158. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/observability/__init__.py +0 -0
  159. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/observability/logging.py +0 -0
  160. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/__init__.py +0 -0
  161. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/child_handle.py +0 -0
  162. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/child_workflow.py +0 -0
  163. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/continue_as_new.py +0 -0
  164. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/define_hook.py +0 -0
  165. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/hooks.py +0 -0
  166. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/resume_hook.py +0 -0
  167. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/schedule.py +0 -0
  168. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/shield.py +0 -0
  169. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/primitives/sleep.py +0 -0
  170. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/runtime/__init__.py +0 -0
  171. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/runtime/base.py +0 -0
  172. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/runtime/celery.py +0 -0
  173. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/runtime/factory.py +0 -0
  174. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/runtime/local.py +0 -0
  175. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/scheduler/__init__.py +0 -0
  176. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/scheduler/local.py +0 -0
  177. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/serialization/__init__.py +0 -0
  178. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/serialization/decoder.py +0 -0
  179. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/serialization/encoder.py +0 -0
  180. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/migrations/__init__.py +0 -0
  181. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/migrations/base.py +0 -0
  182. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/storage/schemas.py +0 -0
  183. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/utils/__init__.py +0 -0
  184. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/utils/duration.py +0 -0
  185. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/utils/helpers.py +0 -0
  186. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/pyworkflow/utils/schedule.py +0 -0
  187. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/setup.cfg +0 -0
  188. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/integration/__init__.py +0 -0
  189. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/integration/test_cancellation.py +0 -0
  190. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/integration/test_cassandra_storage.py +0 -0
  191. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/integration/test_child_workflows.py +0 -0
  192. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/integration/test_continue_as_new.py +0 -0
  193. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/integration/test_dynamodb_storage.py +0 -0
  194. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/integration/test_fault_tolerance.py +0 -0
  195. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/integration/test_schedule_storage.py +0 -0
  196. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/integration/test_schema_migrations.py +0 -0
  197. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/integration/test_singleton.py +0 -0
  198. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/integration/test_workflow_suspended.py +0 -0
  199. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/__init__.py +0 -0
  200. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/backends/__init__.py +0 -0
  201. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/backends/test_cassandra_storage.py +0 -0
  202. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/backends/test_dynamodb_storage.py +0 -0
  203. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/backends/test_postgres_storage.py +0 -0
  204. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/backends/test_sqlite_storage.py +0 -0
  205. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/conftest.py +0 -0
  206. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/storage/__init__.py +0 -0
  207. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/storage/test_migrations.py +0 -0
  208. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_cancellation.py +0 -0
  209. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_child_workflows.py +0 -0
  210. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_cli_worker.py +0 -0
  211. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_continue_as_new.py +0 -0
  212. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_event_limits.py +0 -0
  213. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_executor.py +0 -0
  214. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_fault_tolerance.py +0 -0
  215. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_force_local.py +0 -0
  216. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_hooks.py +0 -0
  217. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_parent_run_id.py +0 -0
  218. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_primitives_from_steps.py +0 -0
  219. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_registry.py +0 -0
  220. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_replay.py +0 -0
  221. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_schedule_schemas.py +0 -0
  222. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_schedule_utils.py +0 -0
  223. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_scheduled_workflow.py +0 -0
  224. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_singleton.py +0 -0
  225. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_step.py +0 -0
  226. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_step_context.py +0 -0
  227. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_validation.py +0 -0
  228. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_workflow.py +0 -0
  229. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.2.0}/tests/unit/test_workflow_suspended.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyworkflow-engine
3
- Version: 0.1.36
3
+ Version: 0.2.0
4
4
  Summary: A Python implementation of durable, event-sourced workflows inspired by Vercel Workflow
5
5
  Author: PyWorkflow Contributors
6
6
  License: MIT
@@ -7,7 +7,7 @@ packages = [{include = "pyworkflow"}]
7
7
 
8
8
  [project]
9
9
  name = "pyworkflow-engine"
10
- version = "0.1.36"
10
+ version = "0.2.0"
11
11
  description = "A Python implementation of durable, event-sourced workflows inspired by Vercel Workflow"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.11"
@@ -29,7 +29,7 @@ Quick Start:
29
29
  >>> run_id = await start(my_workflow, "Alice")
30
30
  """
31
31
 
32
- __version__ = "0.1.36"
32
+ __version__ = "0.2.0"
33
33
 
34
34
  # Configuration
35
35
  from pyworkflow.config import (
@@ -135,6 +135,12 @@ from pyworkflow.primitives.schedule import (
135
135
  )
136
136
  from pyworkflow.primitives.shield import shield
137
137
  from pyworkflow.primitives.sleep import sleep
138
+ from pyworkflow.primitives.step_checkpoint import (
139
+ delete_step_checkpoint,
140
+ load_step_checkpoint,
141
+ save_step_checkpoint,
142
+ )
143
+ from pyworkflow.primitives.step_hook import step_hook
138
144
 
139
145
  # Runtime
140
146
  from pyworkflow.runtime import LocalRuntime, Runtime, get_runtime, register_runtime
@@ -156,6 +162,21 @@ from pyworkflow.storage.schemas import (
156
162
  WorkflowRun,
157
163
  )
158
164
 
165
+ # Streams (pub/sub signal pattern)
166
+ from pyworkflow.streams import (
167
+ CheckpointBackend,
168
+ Signal,
169
+ Stream,
170
+ StreamConsumer,
171
+ StreamStepContext,
172
+ emit,
173
+ get_checkpoint,
174
+ get_current_signal,
175
+ save_checkpoint,
176
+ stream_step,
177
+ stream_workflow,
178
+ )
179
+
159
180
  __all__ = [
160
181
  # Version
161
182
  "__version__",
@@ -264,4 +285,21 @@ __all__ = [
264
285
  "get_logger",
265
286
  "bind_workflow_context",
266
287
  "bind_step_context",
288
+ # Step checkpoint + hooks
289
+ "save_step_checkpoint",
290
+ "load_step_checkpoint",
291
+ "delete_step_checkpoint",
292
+ "step_hook",
293
+ # Streams
294
+ "stream_workflow",
295
+ "stream_step",
296
+ "emit",
297
+ "Signal",
298
+ "Stream",
299
+ "StreamStepContext",
300
+ "StreamConsumer",
301
+ "CheckpointBackend",
302
+ "get_current_signal",
303
+ "get_checkpoint",
304
+ "save_checkpoint",
267
305
  ]
@@ -13,6 +13,7 @@ garbage collector and Celery's saferepr module. It does not affect functionality
13
13
  """
14
14
 
15
15
  import os
16
+ from datetime import timedelta
16
17
  from typing import Any
17
18
 
18
19
  from celery import Celery
@@ -343,8 +344,14 @@ def create_celery_app(
343
344
  # Monitoring
344
345
  "worker_send_task_events": True,
345
346
  "task_send_sent_event": True,
346
- # Beat scheduler (for sleep resumption)
347
- "beat_schedule": {},
347
+ # Beat scheduler (for sleep resumption and periodic tasks)
348
+ "beat_schedule": {
349
+ "pyworkflow-data-retention": {
350
+ "task": "pyworkflow.run_data_retention",
351
+ "schedule": timedelta(hours=24),
352
+ "options": {"queue": "pyworkflow.default"},
353
+ },
354
+ },
348
355
  # Logging
349
356
  "worker_log_format": "[%(asctime)s: %(levelname)s/%(processName)s] %(message)s",
350
357
  "worker_task_log_format": "[%(asctime)s: %(levelname)s/%(processName)s] [%(task_name)s(%(task_id)s)] %(message)s",
@@ -181,9 +181,11 @@ class PyWorkflowScheduler(Scheduler):
181
181
  execute_scheduled_workflow_task.apply_async(
182
182
  kwargs={
183
183
  "schedule_id": schedule.schedule_id,
184
- "scheduled_time": schedule.next_run_time.isoformat()
185
- if schedule.next_run_time
186
- else now.isoformat(),
184
+ "scheduled_time": (
185
+ schedule.next_run_time.isoformat()
186
+ if schedule.next_run_time
187
+ else now.isoformat()
188
+ ),
187
189
  "storage_config": self._storage_config,
188
190
  },
189
191
  queue="pyworkflow.schedules",
@@ -198,14 +198,36 @@ def execute_step_task(
198
198
  _exec_lock_key, self.request.id or "unknown", expiry=self._lock_expiry
199
199
  )
200
200
  if not _exec_lock_acquired:
201
- logger.warning(
202
- "Step already being executed by another worker, skipping duplicate",
203
- run_id=run_id,
204
- step_id=step_id,
205
- step_name=step_name,
206
- existing_worker_task=_exec_lock_backend.get(_exec_lock_key),
207
- )
208
- return None
201
+ # Check for worker-loss re-delivery: when a worker dies mid-step
202
+ # (e.g., spot instance termination), Celery re-delivers the same
203
+ # message with the same task_id (task_reject_on_worker_lost=True).
204
+ # The dead worker's lock still exists in Redis (up to 1h TTL).
205
+ # If the lock holder matches our own task_id, this IS a re-delivery
206
+ # of the same task — force-acquire and proceed.
207
+ _holding_task_id = _exec_lock_backend.get(_exec_lock_key)
208
+ _our_task_id = self.request.id or "unknown"
209
+ if _holding_task_id == _our_task_id:
210
+ logger.warning(
211
+ "Step lock held by same task_id — worker-loss re-delivery detected, "
212
+ "force-acquiring lock",
213
+ run_id=run_id,
214
+ step_id=step_id,
215
+ step_name=step_name,
216
+ task_id=_our_task_id,
217
+ )
218
+ _exec_lock_backend.unlock(_exec_lock_key)
219
+ _exec_lock_acquired = _exec_lock_backend.lock(
220
+ _exec_lock_key, _our_task_id, expiry=self._lock_expiry
221
+ )
222
+ else:
223
+ logger.warning(
224
+ "Step already being executed by another worker, skipping duplicate",
225
+ run_id=run_id,
226
+ step_id=step_id,
227
+ step_name=step_name,
228
+ existing_worker_task=_holding_task_id,
229
+ )
230
+ return None
209
231
  else:
210
232
  _exec_lock_acquired = True # No Redis = no locking
211
233
 
@@ -279,6 +301,18 @@ def execute_step_task(
279
301
  step_id=step_id,
280
302
  )
281
303
 
304
+ # Set up step execution context for checkpoint/hook primitives
305
+ step_exec_key = f"{run_id}:{step_id}"
306
+ step_exec_tokens = None
307
+ try:
308
+ from pyworkflow.primitives.step_checkpoint import (
309
+ set_step_execution_context,
310
+ )
311
+
312
+ step_exec_tokens = set_step_execution_context(step_exec_key, storage)
313
+ except Exception as e:
314
+ logger.warning(f"Failed to set up step execution context: {e}")
315
+
282
316
  # Execute step function
283
317
  try:
284
318
  # Get the original function (unwrapped from decorator)
@@ -339,10 +373,29 @@ def execute_step_task(
339
373
  raise
340
374
 
341
375
  except SuspensionSignal as e:
342
- # A primitive (sleep, hook, child_workflow) raised SuspensionSignal.
343
- # This should not normally happen because primitives detect step worker
344
- # context and adjust behavior. Treat as a fatal error rather than
345
- # silently retrying, which would re-execute the step from scratch.
376
+ # Check if this is a step_hook suspension (supported)
377
+ if e.reason and e.reason.startswith("step_hook:"):
378
+ hook_id = e.data.get("hook_id")
379
+ logger.info(
380
+ f"Step suspended via step_hook: {step_name}",
381
+ run_id=run_id,
382
+ step_id=step_id,
383
+ hook_id=hook_id,
384
+ )
385
+ # Record STEP_SUSPENDED event so the workflow knows this step
386
+ # needs re-dispatch (clears in-progress state during replay)
387
+ run_async(
388
+ _record_step_suspended(
389
+ storage_config=storage_config,
390
+ run_id=run_id,
391
+ step_id=step_id,
392
+ step_name=step_name,
393
+ hook_id=hook_id or "",
394
+ )
395
+ )
396
+ return None
397
+
398
+ # Other SuspensionSignals are not supported from steps
346
399
  logger.error(
347
400
  f"Step raised SuspensionSignal (unsupported from steps): {step_name}",
348
401
  run_id=run_id,
@@ -449,6 +502,12 @@ def execute_step_task(
449
502
  with contextlib.suppress(Exception):
450
503
  _exec_lock_backend.unlock(_exec_lock_key)
451
504
 
505
+ # Clean up step execution context (checkpoint/hook primitives)
506
+ if step_exec_tokens is not None:
507
+ from pyworkflow.primitives.step_checkpoint import reset_step_execution_context
508
+
509
+ reset_step_execution_context(step_exec_tokens)
510
+
452
511
  # Clean up workflow context (must be reset before step context)
453
512
  if workflow_context_token is not None:
454
513
  from pyworkflow.context.base import reset_context
@@ -567,6 +626,63 @@ async def _record_step_completion_and_resume(
567
626
  )
568
627
 
569
628
 
629
+ async def _record_step_suspended(
630
+ storage_config: dict[str, Any] | None,
631
+ run_id: str,
632
+ step_id: str,
633
+ step_name: str,
634
+ hook_id: str,
635
+ ) -> None:
636
+ """
637
+ Record STEP_SUSPENDED event when a step suspends via step_hook.
638
+
639
+ This clears the step's in-progress state during replay so that the
640
+ workflow re-dispatches the step on resume (instead of thinking it's
641
+ still running).
642
+
643
+ Does NOT schedule workflow resumption — that happens when resume_hook()
644
+ is called externally.
645
+ """
646
+ from pyworkflow.engine.events import create_step_suspended_event
647
+
648
+ storage = _get_storage_backend(storage_config)
649
+ if hasattr(storage, "connect"):
650
+ await storage.connect()
651
+
652
+ # Wait for WORKFLOW_SUSPENDED event to avoid sequence number race
653
+ max_wait_attempts = 50
654
+ wait_interval = 0.01
655
+
656
+ for _attempt in range(max_wait_attempts):
657
+ has_suspended = await storage.has_event(
658
+ run_id, EventType.WORKFLOW_SUSPENDED.value, step_id=step_id
659
+ )
660
+ if has_suspended:
661
+ break
662
+ await asyncio.sleep(wait_interval)
663
+ else:
664
+ logger.warning(
665
+ "Timeout waiting for WORKFLOW_SUSPENDED before recording STEP_SUSPENDED",
666
+ run_id=run_id,
667
+ step_id=step_id,
668
+ )
669
+
670
+ event = create_step_suspended_event(
671
+ run_id=run_id,
672
+ step_id=step_id,
673
+ step_name=step_name,
674
+ hook_id=hook_id,
675
+ )
676
+ await storage.record_event(event)
677
+ logger.info(
678
+ "Step suspended event recorded",
679
+ run_id=run_id,
680
+ step_id=step_id,
681
+ step_name=step_name,
682
+ hook_id=hook_id,
683
+ )
684
+
685
+
570
686
  async def _record_step_failure_and_resume(
571
687
  storage_config: dict[str, Any] | None,
572
688
  run_id: str,
@@ -782,13 +898,31 @@ def start_workflow_task(
782
898
  _exec_lock_key, self.request.id or "unknown", expiry=self._lock_expiry
783
899
  )
784
900
  if not _exec_lock_acquired:
785
- logger.warning(
786
- "Workflow start already being executed by another worker, skipping duplicate",
787
- run_id=run_id,
788
- workflow_name=workflow_name,
789
- existing_worker_task=_exec_lock_backend.get(_exec_lock_key),
790
- )
791
- return run_id
901
+ # Check for worker-loss re-delivery (same logic as step tasks):
902
+ # if the lock holder matches our task_id, this is the same message
903
+ # re-delivered after the original worker died.
904
+ _holding_task_id = _exec_lock_backend.get(_exec_lock_key)
905
+ _our_task_id = self.request.id or "unknown"
906
+ if _holding_task_id == _our_task_id:
907
+ logger.warning(
908
+ "Workflow start lock held by same task_id — worker-loss re-delivery "
909
+ "detected, force-acquiring lock",
910
+ run_id=run_id,
911
+ workflow_name=workflow_name,
912
+ task_id=_our_task_id,
913
+ )
914
+ _exec_lock_backend.unlock(_exec_lock_key)
915
+ _exec_lock_acquired = _exec_lock_backend.lock(
916
+ _exec_lock_key, _our_task_id, expiry=self._lock_expiry
917
+ )
918
+ else:
919
+ logger.warning(
920
+ "Workflow start already being executed by another worker, skipping duplicate",
921
+ run_id=run_id,
922
+ workflow_name=workflow_name,
923
+ existing_worker_task=_holding_task_id,
924
+ )
925
+ return run_id
792
926
  else:
793
927
  _exec_lock_acquired = True # No Redis = no locking
794
928
 
@@ -2749,3 +2883,45 @@ async def _handle_continue_as_new_celery(
2749
2883
  )
2750
2884
 
2751
2885
  return new_run_id
2886
+
2887
+
2888
+ @celery_app.task(
2889
+ name="pyworkflow.run_data_retention",
2890
+ base=SingletonWorkflowTask,
2891
+ bind=True,
2892
+ queue="pyworkflow.default",
2893
+ release_lock_on_failure=True,
2894
+ lock_expiry=7200, # 2h safety net
2895
+ )
2896
+ def run_data_retention_task(self: SingletonWorkflowTask) -> dict[str, Any]:
2897
+ """
2898
+ Periodic task: delete workflow runs older than data_retention_days.
2899
+
2900
+ Singleton (only one instance runs at a time). Skips if data_retention_days
2901
+ is not configured.
2902
+ """
2903
+ from datetime import timedelta
2904
+
2905
+ from pyworkflow.config import get_config
2906
+ from pyworkflow.storage.config import storage_to_config
2907
+
2908
+ config = get_config()
2909
+ if config.data_retention_days is None:
2910
+ logger.debug("Data retention not configured; skipping.")
2911
+ return {"deleted": 0, "skipped": True}
2912
+
2913
+ storage = config.storage
2914
+ if storage is None:
2915
+ logger.warning("No storage configured; skipping data retention.")
2916
+ return {"deleted": 0, "skipped": True}
2917
+
2918
+ cutoff = datetime.now(UTC) - timedelta(days=config.data_retention_days)
2919
+ logger.info(
2920
+ "Running data retention: deleting runs updated before {}",
2921
+ cutoff.isoformat(),
2922
+ )
2923
+ storage_config = storage_to_config(storage)
2924
+ backend = _get_storage_backend(storage_config)
2925
+ count = run_async(backend.delete_old_runs(cutoff))
2926
+ logger.info("Data retention complete: deleted {} runs", count)
2927
+ return {"deleted": count, "skipped": False}
@@ -299,12 +299,12 @@ async def list_hooks_cmd(
299
299
  "Name": hook.name or "-",
300
300
  "Status": hook.status.value,
301
301
  "Run ID": hook.run_id,
302
- "Created": hook.created_at.strftime("%Y-%m-%d %H:%M")
303
- if hook.created_at
304
- else "-",
305
- "Expires": hook.expires_at.strftime("%Y-%m-%d %H:%M")
306
- if hook.expires_at
307
- else "-",
302
+ "Created": (
303
+ hook.created_at.strftime("%Y-%m-%d %H:%M") if hook.created_at else "-"
304
+ ),
305
+ "Expires": (
306
+ hook.expires_at.strftime("%Y-%m-%d %H:%M") if hook.expires_at else "-"
307
+ ),
308
308
  }
309
309
  for hook in hooks_list
310
310
  ]
@@ -376,15 +376,15 @@ async def hook_info_cmd(ctx: click.Context, token: str) -> None:
376
376
  "Run ID": hook.run_id,
377
377
  "Name": hook.name or "-",
378
378
  "Status": hook.status.value,
379
- "Created": hook.created_at.strftime("%Y-%m-%d %H:%M:%S")
380
- if hook.created_at
381
- else "-",
382
- "Expires": hook.expires_at.strftime("%Y-%m-%d %H:%M:%S")
383
- if hook.expires_at
384
- else "-",
385
- "Received": hook.received_at.strftime("%Y-%m-%d %H:%M:%S")
386
- if hook.received_at
387
- else "-",
379
+ "Created": (
380
+ hook.created_at.strftime("%Y-%m-%d %H:%M:%S") if hook.created_at else "-"
381
+ ),
382
+ "Expires": (
383
+ hook.expires_at.strftime("%Y-%m-%d %H:%M:%S") if hook.expires_at else "-"
384
+ ),
385
+ "Received": (
386
+ hook.received_at.strftime("%Y-%m-%d %H:%M:%S") if hook.received_at else "-"
387
+ ),
388
388
  }
389
389
 
390
390
  # Show payload if received
@@ -151,9 +151,9 @@ async def list_runs(
151
151
  "Run ID": run.run_id,
152
152
  "Workflow": run.workflow_name,
153
153
  "Status": run.status.value,
154
- "Started": run.started_at.strftime("%Y-%m-%d %H:%M:%S")
155
- if run.started_at
156
- else "-",
154
+ "Started": (
155
+ run.started_at.strftime("%Y-%m-%d %H:%M:%S") if run.started_at else "-"
156
+ ),
157
157
  "Duration": durations.get(run.run_id, "-"),
158
158
  }
159
159
  for run in runs_list
@@ -238,9 +238,9 @@ async def run_status(ctx: click.Context, run_id: str) -> None:
238
238
  "Status": run.status.value,
239
239
  "Created": run.created_at.strftime("%Y-%m-%d %H:%M:%S") if run.created_at else "-",
240
240
  "Started": run.started_at.strftime("%Y-%m-%d %H:%M:%S") if run.started_at else "-",
241
- "Completed": run.completed_at.strftime("%Y-%m-%d %H:%M:%S")
242
- if run.completed_at
243
- else "-",
241
+ "Completed": (
242
+ run.completed_at.strftime("%Y-%m-%d %H:%M:%S") if run.completed_at else "-"
243
+ ),
244
244
  "Duration": duration_str,
245
245
  }
246
246
 
@@ -622,9 +622,9 @@ async def list_children(
622
622
  "Workflow": child.workflow_name,
623
623
  "Status": child.status.value,
624
624
  "Depth": child.nesting_depth,
625
- "Started": child.started_at.strftime("%Y-%m-%d %H:%M:%S")
626
- if child.started_at
627
- else "-",
625
+ "Started": (
626
+ child.started_at.strftime("%Y-%m-%d %H:%M:%S") if child.started_at else "-"
627
+ ),
628
628
  "Duration": _calc_duration(child),
629
629
  }
630
630
  for child in children
@@ -123,9 +123,9 @@ async def list_schedules_cmd(
123
123
  "Workflow": s.workflow_name,
124
124
  "Status": s.status.value,
125
125
  "Schedule": describe_schedule(s.spec),
126
- "Next Run": s.next_run_time.strftime("%Y-%m-%d %H:%M:%S")
127
- if s.next_run_time
128
- else "-",
126
+ "Next Run": (
127
+ s.next_run_time.strftime("%Y-%m-%d %H:%M:%S") if s.next_run_time else "-"
128
+ ),
129
129
  "Runs": f"{s.successful_runs}/{s.total_runs}",
130
130
  }
131
131
  for s in schedules_list
@@ -241,9 +241,9 @@ async def create_schedule_cmd(
241
241
  "status": schedule.status.value,
242
242
  "spec": describe_schedule(schedule.spec),
243
243
  "overlap_policy": schedule.overlap_policy.value,
244
- "next_run_time": schedule.next_run_time.isoformat()
245
- if schedule.next_run_time
246
- else None,
244
+ "next_run_time": (
245
+ schedule.next_run_time.isoformat() if schedule.next_run_time else None
246
+ ),
247
247
  }
248
248
  format_json(data)
249
249
  else:
@@ -308,9 +308,9 @@ async def show_schedule_cmd(
308
308
  "timezone": schedule.spec.timezone,
309
309
  },
310
310
  "overlap_policy": schedule.overlap_policy.value,
311
- "next_run_time": schedule.next_run_time.isoformat()
312
- if schedule.next_run_time
313
- else None,
311
+ "next_run_time": (
312
+ schedule.next_run_time.isoformat() if schedule.next_run_time else None
313
+ ),
314
314
  "last_run_at": schedule.last_run_at.isoformat() if schedule.last_run_at else None,
315
315
  "total_runs": schedule.total_runs,
316
316
  "successful_runs": schedule.successful_runs,
@@ -327,19 +327,25 @@ async def show_schedule_cmd(
327
327
  "Status": schedule.status.value,
328
328
  "Schedule": describe_schedule(schedule.spec),
329
329
  "Overlap Policy": schedule.overlap_policy.value,
330
- "Next Run": schedule.next_run_time.strftime("%Y-%m-%d %H:%M:%S")
331
- if schedule.next_run_time
332
- else "-",
333
- "Last Run": schedule.last_run_at.strftime("%Y-%m-%d %H:%M:%S")
334
- if schedule.last_run_at
335
- else "-",
330
+ "Next Run": (
331
+ schedule.next_run_time.strftime("%Y-%m-%d %H:%M:%S")
332
+ if schedule.next_run_time
333
+ else "-"
334
+ ),
335
+ "Last Run": (
336
+ schedule.last_run_at.strftime("%Y-%m-%d %H:%M:%S")
337
+ if schedule.last_run_at
338
+ else "-"
339
+ ),
336
340
  "Total Runs": schedule.total_runs,
337
341
  "Successful": schedule.successful_runs,
338
342
  "Failed": schedule.failed_runs,
339
343
  "Skipped": schedule.skipped_runs,
340
- "Created": schedule.created_at.strftime("%Y-%m-%d %H:%M:%S")
341
- if schedule.created_at
342
- else "-",
344
+ "Created": (
345
+ schedule.created_at.strftime("%Y-%m-%d %H:%M:%S")
346
+ if schedule.created_at
347
+ else "-"
348
+ ),
343
349
  }
344
350
  format_key_value(data, title=f"Schedule: {schedule_id}")
345
351
 
@@ -440,9 +446,9 @@ async def resume_schedule_cmd(
440
446
  data = {
441
447
  "schedule_id": schedule.schedule_id,
442
448
  "status": schedule.status.value,
443
- "next_run_time": schedule.next_run_time.isoformat()
444
- if schedule.next_run_time
445
- else None,
449
+ "next_run_time": (
450
+ schedule.next_run_time.isoformat() if schedule.next_run_time else None
451
+ ),
446
452
  }
447
453
  format_json(data)
448
454
 
@@ -774,9 +780,9 @@ async def update_schedule_cmd(
774
780
  "workflow_name": schedule.workflow_name,
775
781
  "spec": describe_schedule(schedule.spec),
776
782
  "overlap_policy": schedule.overlap_policy.value,
777
- "next_run_time": schedule.next_run_time.isoformat()
778
- if schedule.next_run_time
779
- else None,
783
+ "next_run_time": (
784
+ schedule.next_run_time.isoformat() if schedule.next_run_time else None
785
+ ),
780
786
  }
781
787
  format_json(data)
782
788
  else:
@@ -18,7 +18,7 @@ Usage:
18
18
  ... )
19
19
 
20
20
  Environment Variables:
21
- PYWORKFLOW_STORAGE_TYPE: Storage backend type (file, memory, sqlite, postgres, mysql)
21
+ PYWORKFLOW_STORAGE_TYPE: Storage backend type (file, memory, sqlite, postgres, mysql, citus)
22
22
  PYWORKFLOW_STORAGE_PATH: Path for file/sqlite backends
23
23
  PYWORKFLOW_POSTGRES_HOST: PostgreSQL host
24
24
  PYWORKFLOW_POSTGRES_PORT: PostgreSQL port
@@ -33,6 +33,7 @@ Environment Variables:
33
33
  PYWORKFLOW_CELERY_BROKER: Celery broker URL
34
34
  PYWORKFLOW_CELERY_RESULT_BACKEND: Celery result backend URL
35
35
  PYWORKFLOW_RUNTIME: Default runtime (local, celery)
36
+ PYWORKFLOW_DATA_RETENTION_DAYS: Days to retain completed/failed/cancelled runs (unset = keep forever)
36
37
  """
37
38
 
38
39
  import os
@@ -58,9 +59,9 @@ def _load_env_storage_config() -> dict[str, Any] | None:
58
59
 
59
60
  storage_type = storage_type.lower()
60
61
 
61
- if storage_type == "postgres":
62
+ if storage_type in ("postgres", "citus"):
62
63
  return {
63
- "type": "postgres",
64
+ "type": storage_type,
64
65
  "host": os.getenv("PYWORKFLOW_POSTGRES_HOST", "localhost"),
65
66
  "port": int(os.getenv("PYWORKFLOW_POSTGRES_PORT", "5432")),
66
67
  "user": os.getenv("PYWORKFLOW_POSTGRES_USER", "pyworkflow"),
@@ -161,6 +162,9 @@ class PyWorkflowConfig:
161
162
  celery_broker: str | None = None
162
163
  aws_region: str | None = None
163
164
 
165
+ # Data retention policy
166
+ data_retention_days: int | None = None # None = keep forever
167
+
164
168
  # Event limit settings (WARNING: Do not modify unless you understand the implications)
165
169
  # These limits prevent runaway workflows from consuming excessive resources
166
170
  event_soft_limit: int = 10_000 # Log warning at this count
@@ -196,11 +200,21 @@ def _config_from_env_and_yaml() -> PyWorkflowConfig:
196
200
  celery_config = yaml_config.get("celery", {})
197
201
  celery_broker = os.getenv("PYWORKFLOW_CELERY_BROKER") or celery_config.get("broker")
198
202
 
203
+ # Data retention: env var > yaml > None (keep forever)
204
+ retention_env = os.getenv("PYWORKFLOW_DATA_RETENTION_DAYS")
205
+ if retention_env is not None:
206
+ data_retention_days: int | None = int(retention_env)
207
+ elif yaml_config.get("retention_days") is not None:
208
+ data_retention_days = int(yaml_config["retention_days"])
209
+ else:
210
+ data_retention_days = None
211
+
199
212
  return PyWorkflowConfig(
200
213
  default_runtime=runtime,
201
214
  default_durable=durable,
202
215
  storage=storage,
203
216
  celery_broker=celery_broker,
217
+ data_retention_days=data_retention_days,
204
218
  )
205
219
 
206
220
 
@@ -348,11 +362,16 @@ def configure_from_yaml(path: str | Path, discover: bool = True) -> None:
348
362
  celery_config = yaml_config.get("celery", {})
349
363
  celery_broker = celery_config.get("broker")
350
364
 
365
+ # Data retention
366
+ retention_raw = yaml_config.get("retention_days")
367
+ data_retention_days: int | None = int(retention_raw) if retention_raw is not None else None
368
+
351
369
  _config = PyWorkflowConfig(
352
370
  default_runtime=runtime,
353
371
  default_durable=durable,
354
372
  storage=storage,
355
373
  celery_broker=celery_broker,
374
+ data_retention_days=data_retention_days,
356
375
  )
357
376
  _config_loaded_from_yaml = True
358
377
 
@@ -94,6 +94,10 @@ class LocalContext(WorkflowContext):
94
94
  self._cancellation_blocked: bool = False
95
95
  self._cancellation_reason: str | None = None
96
96
 
97
+ # Signal/stream state (used by EventReplayer for stream workflows)
98
+ self._signal_waits: dict[str, dict[str, Any]] = {}
99
+ self._received_signals: dict[str, dict[str, Any]] = {}
100
+
97
101
  # Child workflow state
98
102
  self._child_results: dict[str, dict[str, Any]] = {}
99
103
  self._pending_children: dict[str, str] = {} # child_id -> child_run_id
@@ -161,6 +165,13 @@ class LocalContext(WorkflowContext):
161
165
  # Step completed - no longer in progress
162
166
  self._steps_in_progress.discard(step_id)
163
167
 
168
+ elif event.type == EventType.STEP_SUSPENDED:
169
+ step_id = event.data.get("step_id")
170
+ if step_id:
171
+ # Step suspended via step_hook — no longer in progress,
172
+ # needs re-dispatch on workflow resume
173
+ self._steps_in_progress.discard(step_id)
174
+
164
175
  elif event.type == EventType.SLEEP_COMPLETED:
165
176
  sleep_id = event.data.get("sleep_id")
166
177
  self._completed_sleeps.add(sleep_id)