pyworkflow-engine 0.2.6__tar.gz → 0.2.7__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 (230) hide show
  1. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/PKG-INFO +1 -1
  2. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyproject.toml +1 -1
  3. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/__init__.py +1 -1
  4. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/celery/app.py +5 -0
  5. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/celery/tasks.py +100 -0
  6. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/storage/base.py +101 -1
  7. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/storage/cassandra.py +2 -0
  8. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/storage/citus.py +120 -0
  9. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/storage/dynamodb.py +2 -0
  10. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/storage/file.py +2 -0
  11. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/storage/memory.py +108 -5
  12. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/storage/migrations/base.py +38 -0
  13. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/storage/mysql.py +2 -0
  14. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/storage/postgres.py +337 -18
  15. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/storage/sqlite.py +2 -0
  16. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/streams/__init__.py +14 -1
  17. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/streams/consumer.py +41 -0
  18. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/streams/context.py +75 -0
  19. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/streams/dispatcher.py +233 -11
  20. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/streams/emit.py +94 -3
  21. pyworkflow_engine-0.2.7/pyworkflow/streams/runtime.py +272 -0
  22. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/streams/step_context.py +34 -0
  23. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow_engine.egg-info/SOURCES.txt +1 -0
  24. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/integration/test_schema_migrations.py +1 -1
  25. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/CLAUDE.md +0 -0
  26. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/DISTRIBUTED.md +0 -0
  27. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/LICENSE +0 -0
  28. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/MANIFEST.in +0 -0
  29. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/README.md +0 -0
  30. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/RELEASING.md +0 -0
  31. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/docs/architecture.md +0 -0
  32. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/docs/concepts/cancellation.mdx +0 -0
  33. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/docs/concepts/continue-as-new.mdx +0 -0
  34. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/docs/concepts/events.mdx +0 -0
  35. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/docs/concepts/fault-tolerance.mdx +0 -0
  36. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/docs/concepts/hooks.mdx +0 -0
  37. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/docs/concepts/limitations.mdx +0 -0
  38. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/docs/concepts/schedules.mdx +0 -0
  39. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/docs/concepts/sleep.mdx +0 -0
  40. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/docs/concepts/step-context.mdx +0 -0
  41. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/docs/concepts/steps.mdx +0 -0
  42. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/docs/concepts/workflows.mdx +0 -0
  43. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/docs/conventions.md +0 -0
  44. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/docs/guides/brokers.mdx +0 -0
  45. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/docs/guides/cli.mdx +0 -0
  46. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/docs/guides/configuration.mdx +0 -0
  47. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/docs/harness-gaps.md +0 -0
  48. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/docs/introduction.mdx +0 -0
  49. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/docs/layers.md +0 -0
  50. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/docs/quickstart.mdx +0 -0
  51. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/__init__.py +0 -0
  52. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/celery/__init__.py +0 -0
  53. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/celery/durable/docker-compose.yml +0 -0
  54. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/celery/durable/pyworkflow.config.yaml +0 -0
  55. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/celery/durable/workflows/__init__.py +0 -0
  56. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/celery/durable/workflows/basic.py +0 -0
  57. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/celery/durable/workflows/batch_processing.py +0 -0
  58. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/celery/durable/workflows/cancellation.py +0 -0
  59. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/celery/durable/workflows/child_workflow_from_step.py +0 -0
  60. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/celery/durable/workflows/child_workflow_patterns.py +0 -0
  61. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/celery/durable/workflows/child_workflows.py +0 -0
  62. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/celery/durable/workflows/continue_as_new.py +0 -0
  63. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/celery/durable/workflows/fault_tolerance.py +0 -0
  64. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/celery/durable/workflows/hooks.py +0 -0
  65. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/celery/durable/workflows/idempotency.py +0 -0
  66. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/celery/durable/workflows/long_running.py +0 -0
  67. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/celery/durable/workflows/retries.py +0 -0
  68. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/celery/durable/workflows/schedules.py +0 -0
  69. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/celery/durable/workflows/sleep_in_step.py +0 -0
  70. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/celery/durable/workflows/step_context.py +0 -0
  71. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/celery/transient/01_basic_workflow.py +0 -0
  72. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/celery/transient/02_fault_tolerance.py +0 -0
  73. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/celery/transient/__init__.py +0 -0
  74. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/celery/transient/pyworkflow.config.yaml +0 -0
  75. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/local/__init__.py +0 -0
  76. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/local/durable/01_basic_workflow.py +0 -0
  77. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/local/durable/02_file_storage.py +0 -0
  78. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/local/durable/03_retries.py +0 -0
  79. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/local/durable/04_long_running.py +0 -0
  80. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/local/durable/05_event_log.py +0 -0
  81. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/local/durable/06_idempotency.py +0 -0
  82. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/local/durable/07_hooks.py +0 -0
  83. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/local/durable/08_cancellation.py +0 -0
  84. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/local/durable/09_child_workflows.py +0 -0
  85. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/local/durable/10_child_workflow_patterns.py +0 -0
  86. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/local/durable/11_continue_as_new.py +0 -0
  87. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/local/durable/12_schedules.py +0 -0
  88. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/local/durable/13_step_context.py +0 -0
  89. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/local/durable/14_child_workflow_from_step.py +0 -0
  90. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/local/durable/__init__.py +0 -0
  91. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/local/transient/01_quick_tasks.py +0 -0
  92. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/local/transient/02_retries.py +0 -0
  93. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/local/transient/03_sleep.py +0 -0
  94. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/examples/local/transient/__init__.py +0 -0
  95. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/aws/__init__.py +0 -0
  96. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/aws/context.py +0 -0
  97. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/aws/handler.py +0 -0
  98. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/aws/testing.py +0 -0
  99. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/celery/__init__.py +0 -0
  100. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/celery/loop.py +0 -0
  101. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/celery/scheduler.py +0 -0
  102. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/celery/singleton.py +0 -0
  103. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/cli/__init__.py +0 -0
  104. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/cli/__main__.py +0 -0
  105. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/cli/commands/__init__.py +0 -0
  106. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/cli/commands/hooks.py +0 -0
  107. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/cli/commands/quickstart.py +0 -0
  108. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/cli/commands/runs.py +0 -0
  109. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/cli/commands/scheduler.py +0 -0
  110. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/cli/commands/schedules.py +0 -0
  111. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/cli/commands/setup.py +0 -0
  112. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/cli/commands/worker.py +0 -0
  113. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/cli/commands/workflows.py +0 -0
  114. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/cli/output/__init__.py +0 -0
  115. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/cli/output/formatters.py +0 -0
  116. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/cli/output/styles.py +0 -0
  117. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/cli/utils/__init__.py +0 -0
  118. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/cli/utils/async_helpers.py +0 -0
  119. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/cli/utils/config.py +0 -0
  120. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/cli/utils/config_generator.py +0 -0
  121. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/cli/utils/discovery.py +0 -0
  122. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/cli/utils/docker_manager.py +0 -0
  123. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/cli/utils/interactive.py +0 -0
  124. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/cli/utils/storage.py +0 -0
  125. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/config.py +0 -0
  126. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/context/__init__.py +0 -0
  127. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/context/aws.py +0 -0
  128. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/context/base.py +0 -0
  129. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/context/local.py +0 -0
  130. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/context/mock.py +0 -0
  131. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/context/step_context.py +0 -0
  132. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/core/__init__.py +0 -0
  133. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/core/exceptions.py +0 -0
  134. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/core/registry.py +0 -0
  135. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/core/scheduled.py +0 -0
  136. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/core/step.py +0 -0
  137. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/core/validation.py +0 -0
  138. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/core/workflow.py +0 -0
  139. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/discovery.py +0 -0
  140. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/engine/__init__.py +0 -0
  141. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/engine/events.py +0 -0
  142. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/engine/executor.py +0 -0
  143. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/engine/replay.py +0 -0
  144. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/observability/__init__.py +0 -0
  145. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/observability/logging.py +0 -0
  146. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/primitives/__init__.py +0 -0
  147. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/primitives/child_handle.py +0 -0
  148. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/primitives/child_workflow.py +0 -0
  149. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/primitives/continue_as_new.py +0 -0
  150. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/primitives/define_hook.py +0 -0
  151. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/primitives/hooks.py +0 -0
  152. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/primitives/resume_hook.py +0 -0
  153. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/primitives/schedule.py +0 -0
  154. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/primitives/shield.py +0 -0
  155. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/primitives/sleep.py +0 -0
  156. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/primitives/step_checkpoint.py +0 -0
  157. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/primitives/step_hook.py +0 -0
  158. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/runtime/__init__.py +0 -0
  159. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/runtime/base.py +0 -0
  160. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/runtime/celery.py +0 -0
  161. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/runtime/factory.py +0 -0
  162. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/runtime/local.py +0 -0
  163. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/scheduler/__init__.py +0 -0
  164. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/scheduler/local.py +0 -0
  165. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/serialization/__init__.py +0 -0
  166. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/serialization/decoder.py +0 -0
  167. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/serialization/encoder.py +0 -0
  168. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/storage/__init__.py +0 -0
  169. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/storage/config.py +0 -0
  170. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/storage/migrations/__init__.py +0 -0
  171. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/storage/schemas.py +0 -0
  172. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/streams/checkpoint.py +0 -0
  173. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/streams/decorator.py +0 -0
  174. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/streams/registry.py +0 -0
  175. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/streams/signal.py +0 -0
  176. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/utils/__init__.py +0 -0
  177. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/utils/duration.py +0 -0
  178. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/utils/helpers.py +0 -0
  179. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/pyworkflow/utils/schedule.py +0 -0
  180. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/setup.cfg +0 -0
  181. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/integration/__init__.py +0 -0
  182. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/integration/test_cancellation.py +0 -0
  183. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/integration/test_cassandra_storage.py +0 -0
  184. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/integration/test_child_workflows.py +0 -0
  185. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/integration/test_continue_as_new.py +0 -0
  186. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/integration/test_dynamodb_storage.py +0 -0
  187. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/integration/test_fault_tolerance.py +0 -0
  188. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/integration/test_schedule_storage.py +0 -0
  189. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/integration/test_singleton.py +0 -0
  190. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/integration/test_stream_e2e.py +0 -0
  191. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/integration/test_workflow_suspended.py +0 -0
  192. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/__init__.py +0 -0
  193. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/backends/__init__.py +0 -0
  194. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/backends/test_cassandra_storage.py +0 -0
  195. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/backends/test_citus_storage.py +0 -0
  196. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/backends/test_dynamodb_storage.py +0 -0
  197. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/backends/test_postgres_storage.py +0 -0
  198. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/backends/test_sqlite_storage.py +0 -0
  199. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/conftest.py +0 -0
  200. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/storage/__init__.py +0 -0
  201. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/storage/test_migrations.py +0 -0
  202. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_cancellation.py +0 -0
  203. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_child_workflows.py +0 -0
  204. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_cli_worker.py +0 -0
  205. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_continue_as_new.py +0 -0
  206. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_emit.py +0 -0
  207. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_event_limits.py +0 -0
  208. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_executor.py +0 -0
  209. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_fault_tolerance.py +0 -0
  210. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_force_local.py +0 -0
  211. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_hooks.py +0 -0
  212. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_parent_run_id.py +0 -0
  213. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_primitives_from_steps.py +0 -0
  214. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_registry.py +0 -0
  215. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_replay.py +0 -0
  216. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_retention.py +0 -0
  217. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_schedule_schemas.py +0 -0
  218. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_schedule_utils.py +0 -0
  219. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_scheduled_workflow.py +0 -0
  220. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_signal.py +0 -0
  221. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_singleton.py +0 -0
  222. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_step.py +0 -0
  223. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_step_checkpoint.py +0 -0
  224. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_step_context.py +0 -0
  225. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_step_hook.py +0 -0
  226. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_stream_storage.py +0 -0
  227. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_stream_workflow.py +0 -0
  228. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_validation.py +0 -0
  229. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/tests/unit/test_workflow.py +0 -0
  230. {pyworkflow_engine-0.2.6 → pyworkflow_engine-0.2.7}/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.2.6
3
+ Version: 0.2.7
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.2.6"
10
+ version = "0.2.7"
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.2.6"
32
+ __version__ = "0.2.7"
33
33
 
34
34
  # Configuration
35
35
  from pyworkflow.config import (
@@ -335,6 +335,11 @@ def create_celery_app(
335
335
  "schedule": timedelta(hours=24),
336
336
  "options": {"queue": "pyworkflow.default"},
337
337
  },
338
+ "pyworkflow-streams-drain-scheduled": {
339
+ "task": "pyworkflow.streams.drain_scheduled_signals",
340
+ "schedule": timedelta(seconds=2),
341
+ "options": {"queue": "pyworkflow.default"},
342
+ },
338
343
  },
339
344
  # Logging
340
345
  "worker_log_format": "[%(asctime)s: %(levelname)s/%(processName)s] %(message)s",
@@ -2840,3 +2840,103 @@ def run_data_retention_task(self: SingletonWorkflowTask) -> dict[str, Any]:
2840
2840
  count = run_async(backend.delete_old_runs(cutoff))
2841
2841
  logger.info("Data retention complete: deleted {} runs", count)
2842
2842
  return {"deleted": count, "skipped": False}
2843
+
2844
+
2845
+ # =============================================================================
2846
+ # Stream-only celery tasks (private to pyworkflow.streams)
2847
+ # =============================================================================
2848
+
2849
+
2850
+ @celery_app.task(
2851
+ name="pyworkflow.streams.dispatch_signal",
2852
+ queue="pyworkflow.default",
2853
+ )
2854
+ def dispatch_stream_signal_task(
2855
+ signal_id: str,
2856
+ stream_id: str,
2857
+ signal_type: str,
2858
+ payload: dict[str, Any],
2859
+ sequence: int,
2860
+ source_run_id: str | None,
2861
+ stream_run_id: str | None,
2862
+ metadata: dict[str, Any] | None,
2863
+ storage_config: dict[str, Any] | None = None,
2864
+ ) -> None:
2865
+ """Dispatch a previously persisted signal to its subscribed stream steps.
2866
+
2867
+ Reconstructs the Signal from inline kwargs (avoids a DB round-trip),
2868
+ runs the dispatcher logic on the worker, and after step execution
2869
+ completes calls _maybe_resume_stream_parent so the parent @workflow
2870
+ can be resumed via resume_hook() if the aggregate is now terminal.
2871
+ """
2872
+ from pyworkflow.celery.app import _configure_worker_logging
2873
+
2874
+ _configure_worker_logging()
2875
+
2876
+ storage = _get_storage_backend(storage_config)
2877
+
2878
+ from pyworkflow.streams.dispatcher import dispatch_signal
2879
+ from pyworkflow.streams.signal import Signal
2880
+
2881
+ signal = Signal(
2882
+ signal_id=signal_id,
2883
+ stream_id=stream_id,
2884
+ signal_type=signal_type,
2885
+ payload=payload or {},
2886
+ sequence=sequence,
2887
+ source_run_id=source_run_id,
2888
+ stream_run_id=stream_run_id,
2889
+ metadata=metadata or {},
2890
+ )
2891
+
2892
+ run_async(dispatch_signal(signal, storage))
2893
+
2894
+
2895
+ @celery_app.task(
2896
+ name="pyworkflow.streams.drain_scheduled_signals",
2897
+ base=SingletonWorkflowTask,
2898
+ bind=True,
2899
+ queue="pyworkflow.default",
2900
+ release_lock_on_failure=True,
2901
+ lock_expiry=300,
2902
+ )
2903
+ def drain_scheduled_signals_task(self: SingletonWorkflowTask) -> dict[str, Any]:
2904
+ """Periodic task: deliver any due rows from the ``scheduled_signals``
2905
+ table by calling :func:`pyworkflow.streams.emit.emit`. Replaces the
2906
+ in-process poller that previously ran inside ``run_stream_workflow``."""
2907
+ from pyworkflow.celery.app import _configure_worker_logging
2908
+ from pyworkflow.config import get_config
2909
+
2910
+ _configure_worker_logging()
2911
+
2912
+ config = get_config()
2913
+ storage = config.storage
2914
+ if storage is None:
2915
+ return {"delivered": 0, "skipped": True}
2916
+
2917
+ async def _drain() -> int:
2918
+ from pyworkflow.streams.emit import emit
2919
+
2920
+ try:
2921
+ due = await storage.fetch_due_scheduled_signals(datetime.now(UTC), limit=100)
2922
+ except NotImplementedError:
2923
+ return 0
2924
+ delivered = 0
2925
+ for row in due or []:
2926
+ try:
2927
+ await emit(
2928
+ stream_id=row["stream_id"],
2929
+ signal_type=row["signal_type"],
2930
+ payload=row.get("payload") or {},
2931
+ storage=storage,
2932
+ stream_run_id=row.get("stream_run_id"),
2933
+ metadata=row.get("metadata") or {},
2934
+ )
2935
+ await storage.mark_scheduled_signal_delivered(row["id"])
2936
+ delivered += 1
2937
+ except Exception as e: # noqa: BLE001
2938
+ logger.error(f"[drain_scheduled_signals] failed to deliver {row.get('id')}: {e}")
2939
+ return delivered
2940
+
2941
+ count = run_async(_drain())
2942
+ return {"delivered": count, "skipped": False}
@@ -7,6 +7,7 @@ across different backends (File, Redis, SQLite, PostgreSQL).
7
7
 
8
8
  from abc import ABC, abstractmethod
9
9
  from datetime import datetime
10
+ from typing import Any
10
11
 
11
12
  from pyworkflow.engine.events import Event
12
13
  from pyworkflow.storage.schemas import (
@@ -841,6 +842,7 @@ class StorageBackend(ABC):
841
842
  stream_id: str,
842
843
  step_run_id: str,
843
844
  signal_types: list[str],
845
+ stream_run_id: str | None = None,
844
846
  ) -> None:
845
847
  """
846
848
  Register a stream step's subscription to signal types.
@@ -859,6 +861,7 @@ class StorageBackend(ABC):
859
861
  self,
860
862
  stream_id: str,
861
863
  signal_type: str,
864
+ stream_run_id: str | None = None,
862
865
  ) -> list[dict]:
863
866
  """
864
867
  Get step_run_ids waiting for a specific signal type on a stream.
@@ -879,6 +882,7 @@ class StorageBackend(ABC):
879
882
  self,
880
883
  stream_id: str,
881
884
  signal_type: str,
885
+ stream_run_id: str | None = None,
882
886
  ) -> list[dict]:
883
887
  """
884
888
  Get ALL subscriptions for a signal type on a stream, regardless of status.
@@ -897,6 +901,40 @@ class StorageBackend(ABC):
897
901
  """
898
902
  raise NotImplementedError("This storage backend does not support streams")
899
903
 
904
+ async def set_subscription_result(
905
+ self,
906
+ stream_id: str,
907
+ step_run_id: str,
908
+ result: Any,
909
+ ) -> None:
910
+ """Persist a stream step's result payload on its subscription row.
911
+
912
+ Returned to the parent ``@workflow`` via
913
+ ``StreamWorkflowResult.step_results`` so it doesn't have to fish
914
+ through checkpoints to read step output.
915
+ """
916
+ raise NotImplementedError("This storage backend does not support streams")
917
+
918
+ async def set_stream_parent_link(
919
+ self,
920
+ stream_id: str,
921
+ stream_run_id: str,
922
+ parent_run_id: str,
923
+ parent_hook_token: str,
924
+ ) -> None:
925
+ """Record (parent_run_id, parent_hook_token) for a stream run so the
926
+ stream-step dispatcher can resume the parent @workflow via resume_hook()
927
+ once the stream reaches a terminal aggregate state."""
928
+ raise NotImplementedError("This storage backend does not support streams")
929
+
930
+ async def get_stream_parent_link(
931
+ self,
932
+ stream_id: str,
933
+ stream_run_id: str,
934
+ ) -> tuple[str, str] | None:
935
+ """Return the (parent_run_id, parent_hook_token) recorded for a stream run."""
936
+ raise NotImplementedError("This storage backend does not support streams")
937
+
900
938
  async def update_subscription_status(
901
939
  self,
902
940
  stream_id: str,
@@ -909,13 +947,40 @@ class StorageBackend(ABC):
909
947
  Args:
910
948
  stream_id: Stream identifier
911
949
  step_run_id: The stream step's run ID
912
- status: New status ("waiting", "running", "completed")
950
+ status: New status. One of:
951
+ - "waiting": ready for next signal
952
+ - "running": currently executing lifecycle
953
+ - "suspended": alive but waiting for an external resume condition
954
+ (e.g. HITL). Contributes "suspended" to the stream-workflow
955
+ aggregate state.
956
+ - "terminated": permanently done; dispatcher will skip it.
957
+ - "completed": legacy alias for "terminated".
913
958
 
914
959
  Raises:
915
960
  NotImplementedError: If backend doesn't support streams
916
961
  """
917
962
  raise NotImplementedError("This storage backend does not support streams")
918
963
 
964
+ async def get_subscription_states(
965
+ self,
966
+ stream_id: str,
967
+ stream_run_id: str | None = None,
968
+ ) -> list[dict]:
969
+ """
970
+ Return all subscriptions and their statuses for a stream.
971
+
972
+ Used by the stream-workflow runtime to compute the aggregate state
973
+ across all registered ``@stream_step``s.
974
+
975
+ Args:
976
+ stream_id: Stream identifier
977
+ stream_run_id: Optional stream run scope
978
+
979
+ Returns:
980
+ List of dicts with at least ``step_run_id`` and ``status`` keys.
981
+ """
982
+ raise NotImplementedError("This storage backend does not support streams")
983
+
919
984
  async def acknowledge_signal(
920
985
  self,
921
986
  signal_id: str,
@@ -953,6 +1018,41 @@ class StorageBackend(ABC):
953
1018
  """
954
1019
  raise NotImplementedError("This storage backend does not support streams")
955
1020
 
1021
+ # Scheduled Signal Operations (for schedule_signal primitive)
1022
+
1023
+ async def schedule_signal(
1024
+ self,
1025
+ *,
1026
+ stream_id: str,
1027
+ signal_type: str,
1028
+ payload: dict,
1029
+ due_at: "datetime", # noqa: F821 (forward ref; datetime imported by backends)
1030
+ stream_run_id: str | None = None,
1031
+ metadata: dict | None = None,
1032
+ ) -> str:
1033
+ """
1034
+ Persist a signal to be emitted at ``due_at``.
1035
+
1036
+ Used by the ``schedule_signal`` streams primitive for time-delayed
1037
+ signal delivery (e.g. supervisor sleep/wakeup).
1038
+
1039
+ Returns:
1040
+ Opaque scheduled-signal ID.
1041
+ """
1042
+ raise NotImplementedError("This storage backend does not support scheduled signals")
1043
+
1044
+ async def fetch_due_scheduled_signals(
1045
+ self,
1046
+ now: "datetime", # noqa: F821
1047
+ limit: int = 100,
1048
+ ) -> list[dict]:
1049
+ """Return scheduled signals whose ``due_at <= now`` and not yet delivered."""
1050
+ raise NotImplementedError("This storage backend does not support scheduled signals")
1051
+
1052
+ async def mark_scheduled_signal_delivered(self, sched_id: str) -> None:
1053
+ """Mark a scheduled signal as delivered so it is not emitted twice."""
1054
+ raise NotImplementedError("This storage backend does not support scheduled signals")
1055
+
956
1056
  # Checkpoint Operations
957
1057
 
958
1058
  async def save_checkpoint(self, step_run_id: str, data: dict) -> None:
@@ -2101,6 +2101,7 @@ class CassandraStorageBackend(StorageBackend):
2101
2101
  stream_id: str,
2102
2102
  step_run_id: str,
2103
2103
  signal_types: list[str],
2104
+ stream_run_id: str | None = None,
2104
2105
  ) -> None:
2105
2106
  """Register a stream step's subscription to signal types."""
2106
2107
  session = self._ensure_connected()
@@ -2119,6 +2120,7 @@ class CassandraStorageBackend(StorageBackend):
2119
2120
  self,
2120
2121
  stream_id: str,
2121
2122
  signal_type: str,
2123
+ stream_run_id: str | None = None,
2122
2124
  ) -> list[dict]:
2123
2125
  """Get step_run_ids waiting for a specific signal type on a stream."""
2124
2126
  session = self._ensure_connected()
@@ -110,6 +110,95 @@ class CitusMigrationRunner(PostgresMigrationRunner):
110
110
  # Re-distribute the new tables
111
111
  await conn.execute("SELECT create_distributed_table('signals', 'stream_run_id')")
112
112
  await conn.execute("SELECT create_reference_table('signal_acknowledgments')")
113
+ elif migration.version == 4:
114
+ # V4: Drop FK constraints referencing streams table.
115
+ # Citus never created these FKs (cross-shard FKs unsupported),
116
+ # so this is a no-op but we still record the version.
117
+ pass
118
+ elif migration.version == 5:
119
+ # V5: Add stream_run_id to stream_subscriptions + scheduled_signals table
120
+ await conn.execute("""
121
+ DO $$
122
+ BEGIN
123
+ IF EXISTS (
124
+ SELECT FROM information_schema.tables
125
+ WHERE table_name = 'stream_subscriptions'
126
+ ) AND NOT EXISTS (
127
+ SELECT 1 FROM information_schema.columns
128
+ WHERE table_name = 'stream_subscriptions'
129
+ AND column_name = 'stream_run_id'
130
+ ) THEN
131
+ ALTER TABLE stream_subscriptions ADD COLUMN stream_run_id TEXT NULL;
132
+ END IF;
133
+ END $$
134
+ """)
135
+ await conn.execute(
136
+ "CREATE INDEX IF NOT EXISTS idx_subscriptions_stream_run "
137
+ "ON stream_subscriptions(stream_id, stream_run_id)"
138
+ )
139
+ await conn.execute("""
140
+ CREATE TABLE IF NOT EXISTS scheduled_signals (
141
+ id TEXT PRIMARY KEY,
142
+ stream_id TEXT NOT NULL,
143
+ signal_type TEXT NOT NULL,
144
+ payload JSONB NOT NULL DEFAULT '{}',
145
+ due_at TIMESTAMPTZ NOT NULL,
146
+ stream_run_id TEXT,
147
+ metadata JSONB DEFAULT '{}',
148
+ delivered BOOLEAN NOT NULL DEFAULT FALSE,
149
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
150
+ )
151
+ """)
152
+ await conn.execute(
153
+ "CREATE INDEX IF NOT EXISTS idx_scheduled_signals_due "
154
+ "ON scheduled_signals(delivered, due_at)"
155
+ )
156
+ # Distribute as reference table if not already distributed
157
+ already = await conn.fetchval("""
158
+ SELECT EXISTS (
159
+ SELECT 1 FROM pg_dist_partition dp
160
+ JOIN pg_class c ON dp.logicalrelid = c.oid
161
+ WHERE c.relname = 'scheduled_signals'
162
+ )
163
+ """)
164
+ if not already:
165
+ await conn.execute("SELECT create_reference_table('scheduled_signals')")
166
+ elif migration.version == 6:
167
+ # V6: Add parent_run_id + parent_hook_token to stream_subscriptions
168
+ await conn.execute("""
169
+ DO $$
170
+ BEGIN
171
+ IF EXISTS (
172
+ SELECT FROM information_schema.tables
173
+ WHERE table_name = 'stream_subscriptions'
174
+ ) AND NOT EXISTS (
175
+ SELECT 1 FROM information_schema.columns
176
+ WHERE table_name = 'stream_subscriptions'
177
+ AND column_name = 'parent_run_id'
178
+ ) THEN
179
+ ALTER TABLE stream_subscriptions
180
+ ADD COLUMN parent_run_id TEXT NULL,
181
+ ADD COLUMN parent_hook_token TEXT NULL;
182
+ END IF;
183
+ END $$
184
+ """)
185
+ elif migration.version == 7:
186
+ await conn.execute("""
187
+ DO $$
188
+ BEGIN
189
+ IF EXISTS (
190
+ SELECT FROM information_schema.tables
191
+ WHERE table_name = 'stream_subscriptions'
192
+ ) AND NOT EXISTS (
193
+ SELECT 1 FROM information_schema.columns
194
+ WHERE table_name = 'stream_subscriptions'
195
+ AND column_name = 'result'
196
+ ) THEN
197
+ ALTER TABLE stream_subscriptions
198
+ ADD COLUMN result JSONB NULL;
199
+ END IF;
200
+ END $$
201
+ """)
113
202
  elif migration.up_func:
114
203
  await migration.up_func(conn)
115
204
  elif migration.up_sql and migration.up_sql != "SELECT 1":
@@ -410,6 +499,10 @@ class CitusStorageBackend(PostgresStorageBackend):
410
499
  step_run_id TEXT NOT NULL,
411
500
  signal_types JSONB NOT NULL DEFAULT '[]',
412
501
  status TEXT NOT NULL DEFAULT 'waiting',
502
+ stream_run_id TEXT NULL,
503
+ parent_run_id TEXT NULL,
504
+ parent_hook_token TEXT NULL,
505
+ result JSONB NULL,
413
506
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
414
507
  PRIMARY KEY (stream_id, step_run_id)
415
508
  )
@@ -417,6 +510,29 @@ class CitusStorageBackend(PostgresStorageBackend):
417
510
  await conn.execute(
418
511
  "CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON stream_subscriptions(stream_id, status)"
419
512
  )
513
+ await conn.execute(
514
+ "CREATE INDEX IF NOT EXISTS idx_subscriptions_stream_run "
515
+ "ON stream_subscriptions(stream_id, stream_run_id)"
516
+ )
517
+
518
+ # scheduled_signals — reference table (polled globally by runtime)
519
+ await conn.execute("""
520
+ CREATE TABLE IF NOT EXISTS scheduled_signals (
521
+ id TEXT PRIMARY KEY,
522
+ stream_id TEXT NOT NULL,
523
+ signal_type TEXT NOT NULL,
524
+ payload JSONB NOT NULL DEFAULT '{}',
525
+ due_at TIMESTAMPTZ NOT NULL,
526
+ stream_run_id TEXT,
527
+ metadata JSONB DEFAULT '{}',
528
+ delivered BOOLEAN NOT NULL DEFAULT FALSE,
529
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
530
+ )
531
+ """)
532
+ await conn.execute(
533
+ "CREATE INDEX IF NOT EXISTS idx_scheduled_signals_due "
534
+ "ON scheduled_signals(delivered, due_at)"
535
+ )
420
536
 
421
537
  # signal_acknowledgments — reference table (needs cross-shard joins)
422
538
  # Differences vs postgres:
@@ -496,3 +612,7 @@ class CitusStorageBackend(PostgresStorageBackend):
496
612
  # signal_acknowledgments: reference table (needs cross-shard joins)
497
613
  if "signal_acknowledgments" not in distributed:
498
614
  await conn.execute("SELECT create_reference_table('signal_acknowledgments')")
615
+
616
+ # scheduled_signals: reference table (polled globally by runtime)
617
+ if "scheduled_signals" not in distributed:
618
+ await conn.execute("SELECT create_reference_table('scheduled_signals')")
@@ -1686,6 +1686,7 @@ class DynamoDBStorageBackend(StorageBackend):
1686
1686
  stream_id: str,
1687
1687
  step_run_id: str,
1688
1688
  signal_types: list[str],
1689
+ stream_run_id: str | None = None,
1689
1690
  ) -> None:
1690
1691
  """Register a stream step's subscription to signal types."""
1691
1692
  async with self._get_client() as client:
@@ -1710,6 +1711,7 @@ class DynamoDBStorageBackend(StorageBackend):
1710
1711
  self,
1711
1712
  stream_id: str,
1712
1713
  signal_type: str,
1714
+ stream_run_id: str | None = None,
1713
1715
  ) -> list[dict]:
1714
1716
  """Get step_run_ids waiting for a specific signal type on a stream."""
1715
1717
  async with self._get_client() as client:
@@ -1187,6 +1187,7 @@ class FileStorageBackend(StorageBackend):
1187
1187
  stream_id: str,
1188
1188
  step_run_id: str,
1189
1189
  signal_types: list[str],
1190
+ stream_run_id: str | None = None,
1190
1191
  ) -> None:
1191
1192
  """Register a stream step's subscription to signal types."""
1192
1193
  sub_file = self.subscriptions_dir / f"{stream_id}__{step_run_id}.json"
@@ -1210,6 +1211,7 @@ class FileStorageBackend(StorageBackend):
1210
1211
  self,
1211
1212
  stream_id: str,
1212
1213
  signal_type: str,
1214
+ stream_run_id: str | None = None,
1213
1215
  ) -> list[dict]:
1214
1216
  """Get step_run_ids waiting for a specific signal type on a stream."""
1215
1217
 
@@ -746,6 +746,7 @@ class InMemoryStorageBackend(StorageBackend):
746
746
  stream_id: str,
747
747
  step_run_id: str,
748
748
  signal_types: list[str],
749
+ stream_run_id: str | None = None,
749
750
  ) -> None:
750
751
  """Register a stream step's subscription to signal types."""
751
752
  with self._lock:
@@ -755,34 +756,136 @@ class InMemoryStorageBackend(StorageBackend):
755
756
  "step_run_id": step_run_id,
756
757
  "signal_types": signal_types,
757
758
  "status": "waiting",
759
+ "stream_run_id": stream_run_id,
758
760
  "created_at": datetime.now(UTC).isoformat(),
759
761
  }
760
762
 
763
+ async def get_subscription_states(
764
+ self,
765
+ stream_id: str,
766
+ stream_run_id: str | None = None,
767
+ ) -> list[dict]:
768
+ """Return all subscriptions and their statuses for a stream run."""
769
+ with self._lock:
770
+ result = []
771
+ for (sid, _), sub in self._subscriptions.items():
772
+ if sid != stream_id:
773
+ continue
774
+ if stream_run_id is not None and sub.get("stream_run_id") not in (
775
+ stream_run_id,
776
+ None,
777
+ ):
778
+ continue
779
+ result.append(
780
+ {
781
+ "stream_id": sub["stream_id"],
782
+ "step_run_id": sub["step_run_id"],
783
+ "status": sub["status"],
784
+ "stream_run_id": sub.get("stream_run_id"),
785
+ }
786
+ )
787
+ return result
788
+
789
+ async def schedule_signal(
790
+ self,
791
+ *,
792
+ stream_id: str,
793
+ signal_type: str,
794
+ payload: dict,
795
+ due_at: datetime,
796
+ stream_run_id: str | None = None,
797
+ metadata: dict | None = None,
798
+ ) -> str:
799
+ """Persist a signal to be emitted at ``due_at``."""
800
+ import uuid as _uuid
801
+
802
+ with self._lock:
803
+ if not hasattr(self, "_scheduled_signals"):
804
+ self._scheduled_signals: dict[str, dict] = {}
805
+ sched_id = f"sched_{_uuid.uuid4().hex[:16]}"
806
+ self._scheduled_signals[sched_id] = {
807
+ "id": sched_id,
808
+ "stream_id": stream_id,
809
+ "signal_type": signal_type,
810
+ "payload": payload,
811
+ "due_at": due_at,
812
+ "stream_run_id": stream_run_id,
813
+ "metadata": metadata or {},
814
+ "delivered": False,
815
+ }
816
+ return sched_id
817
+
818
+ async def fetch_due_scheduled_signals(
819
+ self,
820
+ now: datetime,
821
+ limit: int = 100,
822
+ ) -> list[dict]:
823
+ """Return scheduled signals whose ``due_at <= now`` and not delivered."""
824
+ with self._lock:
825
+ if not hasattr(self, "_scheduled_signals"):
826
+ return []
827
+ due = []
828
+ for row in self._scheduled_signals.values():
829
+ if row["delivered"]:
830
+ continue
831
+ if row["due_at"] <= now:
832
+ due.append(dict(row))
833
+ if len(due) >= limit:
834
+ break
835
+ return due
836
+
837
+ async def mark_scheduled_signal_delivered(self, sched_id: str) -> None:
838
+ with self._lock:
839
+ if not hasattr(self, "_scheduled_signals"):
840
+ return
841
+ row = self._scheduled_signals.get(sched_id)
842
+ if row:
843
+ row["delivered"] = True
844
+
761
845
  async def get_waiting_steps(
762
846
  self,
763
847
  stream_id: str,
764
848
  signal_type: str,
849
+ stream_run_id: str | None = None,
765
850
  ) -> list[dict]:
766
851
  """Get step_run_ids waiting for a specific signal type on a stream."""
767
852
  with self._lock:
768
853
  result = []
769
854
  for (sid, _), sub in self._subscriptions.items():
770
- if sid == stream_id and sub["status"] == "waiting":
771
- if signal_type in sub["signal_types"]:
772
- result.append(sub)
855
+ if sid != stream_id:
856
+ continue
857
+ if sub["status"] not in ("waiting", "suspended"):
858
+ continue
859
+ if signal_type not in sub["signal_types"]:
860
+ continue
861
+ if stream_run_id is not None and sub.get("stream_run_id") not in (
862
+ stream_run_id,
863
+ None,
864
+ ):
865
+ continue
866
+ result.append(sub)
773
867
  return result
774
868
 
775
869
  async def get_subscriptions_for_stream(
776
870
  self,
777
871
  stream_id: str,
778
872
  signal_type: str,
873
+ stream_run_id: str | None = None,
779
874
  ) -> list[dict]:
780
875
  """Get ALL subscriptions for a signal type, regardless of status."""
781
876
  with self._lock:
782
877
  result = []
783
878
  for (sid, _), sub in self._subscriptions.items():
784
- if sid == stream_id and signal_type in sub["signal_types"]:
785
- result.append(sub)
879
+ if sid != stream_id:
880
+ continue
881
+ if signal_type not in sub["signal_types"]:
882
+ continue
883
+ if stream_run_id is not None and sub.get("stream_run_id") not in (
884
+ stream_run_id,
885
+ None,
886
+ ):
887
+ continue
888
+ result.append(sub)
786
889
  return result
787
890
 
788
891
  async def update_subscription_status(