pyworkflow-engine 0.2.3__tar.gz → 0.2.6__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.2.3 → pyworkflow_engine-0.2.6}/PKG-INFO +1 -1
  2. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyproject.toml +1 -1
  3. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/__init__.py +1 -1
  4. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/celery/tasks.py +13 -0
  5. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/context/local.py +0 -25
  6. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/storage/base.py +58 -0
  7. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/storage/cassandra.py +320 -0
  8. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/storage/citus.py +111 -0
  9. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/storage/dynamodb.py +304 -0
  10. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/storage/file.py +266 -0
  11. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/storage/memory.py +57 -5
  12. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/storage/migrations/base.py +20 -0
  13. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/storage/mysql.py +341 -0
  14. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/storage/postgres.py +483 -0
  15. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/storage/sqlite.py +425 -0
  16. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/streams/context.py +20 -2
  17. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/streams/decorator.py +20 -3
  18. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/streams/dispatcher.py +98 -13
  19. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/streams/emit.py +18 -0
  20. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/streams/signal.py +1 -0
  21. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/integration/test_schema_migrations.py +1 -1
  22. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/CLAUDE.md +0 -0
  23. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/DISTRIBUTED.md +0 -0
  24. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/LICENSE +0 -0
  25. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/MANIFEST.in +0 -0
  26. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/README.md +0 -0
  27. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/RELEASING.md +0 -0
  28. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/docs/architecture.md +0 -0
  29. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/docs/concepts/cancellation.mdx +0 -0
  30. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/docs/concepts/continue-as-new.mdx +0 -0
  31. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/docs/concepts/events.mdx +0 -0
  32. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/docs/concepts/fault-tolerance.mdx +0 -0
  33. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/docs/concepts/hooks.mdx +0 -0
  34. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/docs/concepts/limitations.mdx +0 -0
  35. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/docs/concepts/schedules.mdx +0 -0
  36. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/docs/concepts/sleep.mdx +0 -0
  37. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/docs/concepts/step-context.mdx +0 -0
  38. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/docs/concepts/steps.mdx +0 -0
  39. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/docs/concepts/workflows.mdx +0 -0
  40. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/docs/conventions.md +0 -0
  41. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/docs/guides/brokers.mdx +0 -0
  42. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/docs/guides/cli.mdx +0 -0
  43. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/docs/guides/configuration.mdx +0 -0
  44. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/docs/harness-gaps.md +0 -0
  45. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/docs/introduction.mdx +0 -0
  46. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/docs/layers.md +0 -0
  47. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/docs/quickstart.mdx +0 -0
  48. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/__init__.py +0 -0
  49. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/celery/__init__.py +0 -0
  50. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/celery/durable/docker-compose.yml +0 -0
  51. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/celery/durable/pyworkflow.config.yaml +0 -0
  52. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/celery/durable/workflows/__init__.py +0 -0
  53. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/celery/durable/workflows/basic.py +0 -0
  54. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/celery/durable/workflows/batch_processing.py +0 -0
  55. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/celery/durable/workflows/cancellation.py +0 -0
  56. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/celery/durable/workflows/child_workflow_from_step.py +0 -0
  57. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/celery/durable/workflows/child_workflow_patterns.py +0 -0
  58. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/celery/durable/workflows/child_workflows.py +0 -0
  59. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/celery/durable/workflows/continue_as_new.py +0 -0
  60. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/celery/durable/workflows/fault_tolerance.py +0 -0
  61. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/celery/durable/workflows/hooks.py +0 -0
  62. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/celery/durable/workflows/idempotency.py +0 -0
  63. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/celery/durable/workflows/long_running.py +0 -0
  64. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/celery/durable/workflows/retries.py +0 -0
  65. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/celery/durable/workflows/schedules.py +0 -0
  66. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/celery/durable/workflows/sleep_in_step.py +0 -0
  67. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/celery/durable/workflows/step_context.py +0 -0
  68. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/celery/transient/01_basic_workflow.py +0 -0
  69. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/celery/transient/02_fault_tolerance.py +0 -0
  70. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/celery/transient/__init__.py +0 -0
  71. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/celery/transient/pyworkflow.config.yaml +0 -0
  72. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/local/__init__.py +0 -0
  73. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/local/durable/01_basic_workflow.py +0 -0
  74. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/local/durable/02_file_storage.py +0 -0
  75. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/local/durable/03_retries.py +0 -0
  76. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/local/durable/04_long_running.py +0 -0
  77. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/local/durable/05_event_log.py +0 -0
  78. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/local/durable/06_idempotency.py +0 -0
  79. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/local/durable/07_hooks.py +0 -0
  80. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/local/durable/08_cancellation.py +0 -0
  81. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/local/durable/09_child_workflows.py +0 -0
  82. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/local/durable/10_child_workflow_patterns.py +0 -0
  83. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/local/durable/11_continue_as_new.py +0 -0
  84. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/local/durable/12_schedules.py +0 -0
  85. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/local/durable/13_step_context.py +0 -0
  86. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/local/durable/14_child_workflow_from_step.py +0 -0
  87. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/local/durable/__init__.py +0 -0
  88. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/local/transient/01_quick_tasks.py +0 -0
  89. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/local/transient/02_retries.py +0 -0
  90. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/local/transient/03_sleep.py +0 -0
  91. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/examples/local/transient/__init__.py +0 -0
  92. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/aws/__init__.py +0 -0
  93. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/aws/context.py +0 -0
  94. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/aws/handler.py +0 -0
  95. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/aws/testing.py +0 -0
  96. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/celery/__init__.py +0 -0
  97. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/celery/app.py +0 -0
  98. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/celery/loop.py +0 -0
  99. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/celery/scheduler.py +0 -0
  100. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/celery/singleton.py +0 -0
  101. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/cli/__init__.py +0 -0
  102. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/cli/__main__.py +0 -0
  103. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/cli/commands/__init__.py +0 -0
  104. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/cli/commands/hooks.py +0 -0
  105. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/cli/commands/quickstart.py +0 -0
  106. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/cli/commands/runs.py +0 -0
  107. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/cli/commands/scheduler.py +0 -0
  108. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/cli/commands/schedules.py +0 -0
  109. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/cli/commands/setup.py +0 -0
  110. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/cli/commands/worker.py +0 -0
  111. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/cli/commands/workflows.py +0 -0
  112. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/cli/output/__init__.py +0 -0
  113. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/cli/output/formatters.py +0 -0
  114. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/cli/output/styles.py +0 -0
  115. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/cli/utils/__init__.py +0 -0
  116. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/cli/utils/async_helpers.py +0 -0
  117. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/cli/utils/config.py +0 -0
  118. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/cli/utils/config_generator.py +0 -0
  119. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/cli/utils/discovery.py +0 -0
  120. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/cli/utils/docker_manager.py +0 -0
  121. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/cli/utils/interactive.py +0 -0
  122. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/cli/utils/storage.py +0 -0
  123. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/config.py +0 -0
  124. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/context/__init__.py +0 -0
  125. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/context/aws.py +0 -0
  126. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/context/base.py +0 -0
  127. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/context/mock.py +0 -0
  128. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/context/step_context.py +0 -0
  129. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/core/__init__.py +0 -0
  130. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/core/exceptions.py +0 -0
  131. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/core/registry.py +0 -0
  132. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/core/scheduled.py +0 -0
  133. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/core/step.py +0 -0
  134. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/core/validation.py +0 -0
  135. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/core/workflow.py +0 -0
  136. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/discovery.py +0 -0
  137. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/engine/__init__.py +0 -0
  138. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/engine/events.py +0 -0
  139. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/engine/executor.py +0 -0
  140. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/engine/replay.py +0 -0
  141. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/observability/__init__.py +0 -0
  142. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/observability/logging.py +0 -0
  143. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/primitives/__init__.py +0 -0
  144. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/primitives/child_handle.py +0 -0
  145. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/primitives/child_workflow.py +0 -0
  146. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/primitives/continue_as_new.py +0 -0
  147. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/primitives/define_hook.py +0 -0
  148. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/primitives/hooks.py +0 -0
  149. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/primitives/resume_hook.py +0 -0
  150. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/primitives/schedule.py +0 -0
  151. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/primitives/shield.py +0 -0
  152. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/primitives/sleep.py +0 -0
  153. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/primitives/step_checkpoint.py +0 -0
  154. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/primitives/step_hook.py +0 -0
  155. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/runtime/__init__.py +0 -0
  156. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/runtime/base.py +0 -0
  157. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/runtime/celery.py +0 -0
  158. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/runtime/factory.py +0 -0
  159. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/runtime/local.py +0 -0
  160. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/scheduler/__init__.py +0 -0
  161. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/scheduler/local.py +0 -0
  162. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/serialization/__init__.py +0 -0
  163. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/serialization/decoder.py +0 -0
  164. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/serialization/encoder.py +0 -0
  165. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/storage/__init__.py +0 -0
  166. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/storage/config.py +0 -0
  167. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/storage/migrations/__init__.py +0 -0
  168. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/storage/schemas.py +0 -0
  169. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/streams/__init__.py +0 -0
  170. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/streams/checkpoint.py +0 -0
  171. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/streams/consumer.py +0 -0
  172. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/streams/registry.py +0 -0
  173. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/streams/step_context.py +0 -0
  174. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/utils/__init__.py +0 -0
  175. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/utils/duration.py +0 -0
  176. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/utils/helpers.py +0 -0
  177. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow/utils/schedule.py +0 -0
  178. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/pyworkflow_engine.egg-info/SOURCES.txt +0 -0
  179. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/setup.cfg +0 -0
  180. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/integration/__init__.py +0 -0
  181. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/integration/test_cancellation.py +0 -0
  182. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/integration/test_cassandra_storage.py +0 -0
  183. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/integration/test_child_workflows.py +0 -0
  184. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/integration/test_continue_as_new.py +0 -0
  185. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/integration/test_dynamodb_storage.py +0 -0
  186. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/integration/test_fault_tolerance.py +0 -0
  187. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/integration/test_schedule_storage.py +0 -0
  188. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/integration/test_singleton.py +0 -0
  189. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/integration/test_stream_e2e.py +0 -0
  190. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/integration/test_workflow_suspended.py +0 -0
  191. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/__init__.py +0 -0
  192. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/backends/__init__.py +0 -0
  193. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/backends/test_cassandra_storage.py +0 -0
  194. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/backends/test_citus_storage.py +0 -0
  195. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/backends/test_dynamodb_storage.py +0 -0
  196. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/backends/test_postgres_storage.py +0 -0
  197. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/backends/test_sqlite_storage.py +0 -0
  198. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/conftest.py +0 -0
  199. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/storage/__init__.py +0 -0
  200. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/storage/test_migrations.py +0 -0
  201. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_cancellation.py +0 -0
  202. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_child_workflows.py +0 -0
  203. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_cli_worker.py +0 -0
  204. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_continue_as_new.py +0 -0
  205. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_emit.py +0 -0
  206. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_event_limits.py +0 -0
  207. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_executor.py +0 -0
  208. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_fault_tolerance.py +0 -0
  209. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_force_local.py +0 -0
  210. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_hooks.py +0 -0
  211. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_parent_run_id.py +0 -0
  212. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_primitives_from_steps.py +0 -0
  213. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_registry.py +0 -0
  214. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_replay.py +0 -0
  215. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_retention.py +0 -0
  216. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_schedule_schemas.py +0 -0
  217. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_schedule_utils.py +0 -0
  218. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_scheduled_workflow.py +0 -0
  219. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_signal.py +0 -0
  220. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_singleton.py +0 -0
  221. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_step.py +0 -0
  222. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_step_checkpoint.py +0 -0
  223. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_step_context.py +0 -0
  224. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_step_hook.py +0 -0
  225. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_stream_storage.py +0 -0
  226. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_stream_workflow.py +0 -0
  227. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_validation.py +0 -0
  228. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/tests/unit/test_workflow.py +0 -0
  229. {pyworkflow_engine-0.2.3 → pyworkflow_engine-0.2.6}/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.3
3
+ Version: 0.2.6
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.3"
10
+ version = "0.2.6"
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.3"
32
+ __version__ = "0.2.6"
33
33
 
34
34
  # Configuration
35
35
  from pyworkflow.config import (
@@ -2462,6 +2462,9 @@ async def _resume_workflow_on_worker(
2462
2462
  run_id=run_id,
2463
2463
  step_id=step_id,
2464
2464
  )
2465
+ resume_workflow_task.release_lock(
2466
+ task_args=[run_id], task_kwargs={"storage_config": storage_config}
2467
+ )
2465
2468
  schedule_workflow_resumption(
2466
2469
  run_id,
2467
2470
  datetime.now(UTC),
@@ -2481,6 +2484,9 @@ async def _resume_workflow_on_worker(
2481
2484
  run_id=run_id,
2482
2485
  hook_id=hook_id,
2483
2486
  )
2487
+ resume_workflow_task.release_lock(
2488
+ task_args=[run_id], task_kwargs={"storage_config": storage_config}
2489
+ )
2484
2490
  schedule_workflow_resumption(
2485
2491
  run_id,
2486
2492
  datetime.now(UTC),
@@ -2492,6 +2498,13 @@ async def _resume_workflow_on_worker(
2492
2498
  # Schedule automatic resumption if we have a resume_at time
2493
2499
  resume_at = e.data.get("resume_at") if e.data else None
2494
2500
  if resume_at:
2501
+ # Release the singleton lock BEFORE scheduling the next resume.
2502
+ # Without this, the new resume_workflow_task.apply_async() sees
2503
+ # the lock still held by the current (calling) task and silently
2504
+ # drops the schedule as a duplicate.
2505
+ resume_workflow_task.release_lock(
2506
+ task_args=[run_id], task_kwargs={"storage_config": storage_config}
2507
+ )
2495
2508
  schedule_workflow_resumption(
2496
2509
  run_id, resume_at, storage_config=storage_config, triggered_by="start_sleep_hook"
2497
2510
  )
@@ -121,31 +121,6 @@ class LocalContext(WorkflowContext):
121
121
  self._replay_events(event_log)
122
122
  self._is_replaying = False
123
123
 
124
- def _extract_counter_from_id(self, id_string: str) -> int:
125
- """Extract counter value from hook_id or sleep_id.
126
-
127
- Formats:
128
- - hook_{name}_{counter}
129
- - sleep_{counter}_{duration}s
130
-
131
- Args:
132
- id_string: The hook_id or sleep_id string
133
-
134
- Returns:
135
- The counter value, or 0 if parsing fails
136
- """
137
- try:
138
- parts = id_string.split("_")
139
- if id_string.startswith("hook_"):
140
- # hook_{name}_{counter} - counter is last part
141
- return int(parts[-1])
142
- elif id_string.startswith("sleep_"):
143
- # sleep_{counter}_{duration}s - counter is second part
144
- return int(parts[1])
145
- except (ValueError, IndexError):
146
- pass
147
- return 0
148
-
149
124
  def _replay_events(self, events: list[Any]) -> None:
150
125
  """Replay events to restore state."""
151
126
  from pyworkflow.engine.events import EventType
@@ -755,6 +755,7 @@ class StorageBackend(ABC):
755
755
  signal_type: str,
756
756
  payload: dict,
757
757
  source_run_id: str | None = None,
758
+ stream_run_id: str | None = None,
758
759
  metadata: dict | None = None,
759
760
  ) -> int:
760
761
  """
@@ -766,6 +767,7 @@ class StorageBackend(ABC):
766
767
  signal_type: Signal type (e.g., "task.created")
767
768
  payload: Signal payload data
768
769
  source_run_id: Optional source workflow run ID
770
+ stream_run_id: Optional stream run identifier for grouping signals
769
771
  metadata: Optional signal metadata
770
772
 
771
773
  Returns:
@@ -798,6 +800,40 @@ class StorageBackend(ABC):
798
800
  """
799
801
  raise NotImplementedError("This storage backend does not support streams")
800
802
 
803
+ async def query_stream_signals(
804
+ self,
805
+ stream_id: str,
806
+ stream_run_id: str,
807
+ *,
808
+ source_run_id: str | None = None,
809
+ signal_type: str | None = None,
810
+ after_sequence: int | None = None,
811
+ before_sequence: int | None = None,
812
+ last_n: int | None = None,
813
+ limit: int = 50,
814
+ ) -> list[dict]:
815
+ """
816
+ Query signals from a stream with rich filtering.
817
+
818
+ Args:
819
+ stream_id: Stream identifier.
820
+ source_run_id: Filter to signals from a specific workflow run.
821
+ stream_run_id: Filter to signals from a specific stream run.
822
+ signal_type: Filter by signal type (e.g. ``"task.completed"``).
823
+ after_sequence: Only include signals with ``sequence >= value``.
824
+ before_sequence: Only include signals with ``sequence <= value``.
825
+ last_n: Return the N most recent signals (overrides
826
+ ``after_sequence`` and ``limit``).
827
+ limit: Maximum number of signals to return.
828
+
829
+ Returns:
830
+ List of signal dicts ordered by sequence ASC.
831
+
832
+ Raises:
833
+ NotImplementedError: If backend doesn't support streams.
834
+ """
835
+ raise NotImplementedError("This storage backend does not support streams")
836
+
801
837
  # Subscription Operations
802
838
 
803
839
  async def register_stream_subscription(
@@ -839,6 +875,28 @@ class StorageBackend(ABC):
839
875
  """
840
876
  raise NotImplementedError("This storage backend does not support streams")
841
877
 
878
+ async def get_subscriptions_for_stream(
879
+ self,
880
+ stream_id: str,
881
+ signal_type: str,
882
+ ) -> list[dict]:
883
+ """
884
+ Get ALL subscriptions for a signal type on a stream, regardless of status.
885
+
886
+ Unlike ``get_waiting_steps`` which only returns ``"waiting"``
887
+ subscriptions, this returns subscriptions in any status (waiting,
888
+ running, etc.). Used to avoid creating duplicate subscriptions
889
+ when a step is currently executing.
890
+
891
+ Args:
892
+ stream_id: Stream identifier
893
+ signal_type: Signal type to match
894
+
895
+ Returns:
896
+ List of dicts with step subscription info
897
+ """
898
+ raise NotImplementedError("This storage backend does not support streams")
899
+
842
900
  async def update_subscription_status(
843
901
  self,
844
902
  stream_id: str,
@@ -179,6 +179,7 @@ class CassandraStorageBackend(StorageBackend):
179
179
  await self._create_hooks_tables(session)
180
180
  await self._create_cancellation_flags_table(session)
181
181
  await self._create_checkpoints_table(session)
182
+ await self._create_streams_tables(session)
182
183
  await self._create_schedules_tables(session)
183
184
 
184
185
  # Create query pattern tables
@@ -325,6 +326,52 @@ class CassandraStorageBackend(StorageBackend):
325
326
  )
326
327
  """)
327
328
 
329
+ async def _create_streams_tables(self, session: Session) -> None:
330
+ """Create stream-related tables."""
331
+ session.execute("""
332
+ CREATE TABLE IF NOT EXISTS streams (
333
+ stream_id TEXT PRIMARY KEY,
334
+ status TEXT,
335
+ created_at TIMESTAMP,
336
+ metadata TEXT
337
+ )
338
+ """)
339
+
340
+ session.execute("""
341
+ CREATE TABLE IF NOT EXISTS signals (
342
+ stream_run_id TEXT,
343
+ sequence INT,
344
+ signal_id TEXT,
345
+ stream_id TEXT,
346
+ signal_type TEXT,
347
+ payload TEXT,
348
+ published_at TIMESTAMP,
349
+ source_run_id TEXT,
350
+ metadata TEXT,
351
+ PRIMARY KEY ((stream_id, stream_run_id), sequence)
352
+ ) WITH CLUSTERING ORDER BY (sequence ASC)
353
+ """)
354
+
355
+ session.execute("""
356
+ CREATE TABLE IF NOT EXISTS stream_subscriptions (
357
+ stream_id TEXT,
358
+ step_run_id TEXT,
359
+ signal_types TEXT,
360
+ status TEXT,
361
+ created_at TIMESTAMP,
362
+ PRIMARY KEY (stream_id, step_run_id)
363
+ )
364
+ """)
365
+
366
+ session.execute("""
367
+ CREATE TABLE IF NOT EXISTS signal_acknowledgments (
368
+ signal_id TEXT,
369
+ step_run_id TEXT,
370
+ acknowledged_at TIMESTAMP,
371
+ PRIMARY KEY (signal_id, step_run_id)
372
+ )
373
+ """)
374
+
328
375
  async def _create_schedules_tables(self, session: Session) -> None:
329
376
  """Create schedules tables."""
330
377
  # Main schedules table
@@ -1931,3 +1978,276 @@ class CassandraStorageBackend(StorageBackend):
1931
1978
  running_run_ids=json.loads(row.running_run_ids) if row.running_run_ids else [],
1932
1979
  buffered_count=row.buffered_count or 0,
1933
1980
  )
1981
+
1982
+ # Stream Operations
1983
+
1984
+ async def create_stream(self, stream_id: str, metadata: dict | None = None) -> None:
1985
+ """Create a new stream."""
1986
+ session = self._ensure_connected()
1987
+ now = datetime.now(UTC)
1988
+
1989
+ session.execute(
1990
+ SimpleStatement(
1991
+ "INSERT INTO streams (stream_id, status, created_at, metadata) "
1992
+ "VALUES (%s, %s, %s, %s)",
1993
+ consistency_level=self.write_consistency,
1994
+ ),
1995
+ (stream_id, "active", now, json.dumps(metadata or {})),
1996
+ )
1997
+
1998
+ async def get_stream(self, stream_id: str) -> dict | None:
1999
+ """Get a stream by ID."""
2000
+ session = self._ensure_connected()
2001
+
2002
+ row = session.execute(
2003
+ SimpleStatement(
2004
+ "SELECT stream_id, status, created_at, metadata FROM streams WHERE stream_id = %s",
2005
+ consistency_level=self.read_consistency,
2006
+ ),
2007
+ (stream_id,),
2008
+ ).one()
2009
+
2010
+ if row is None:
2011
+ return None
2012
+ return {
2013
+ "stream_id": row.stream_id,
2014
+ "status": row.status,
2015
+ "created_at": row.created_at.isoformat() if row.created_at else None,
2016
+ "metadata": json.loads(row.metadata) if row.metadata else {},
2017
+ }
2018
+
2019
+ async def publish_signal(
2020
+ self,
2021
+ signal_id: str,
2022
+ stream_id: str,
2023
+ signal_type: str,
2024
+ payload: dict,
2025
+ source_run_id: str | None = None,
2026
+ stream_run_id: str | None = None,
2027
+ metadata: dict | None = None,
2028
+ ) -> int:
2029
+ """Publish a signal to a stream."""
2030
+ session = self._ensure_connected()
2031
+ now = datetime.now(UTC)
2032
+
2033
+ # Get next sequence number (scoped per stream_run_id)
2034
+ run_key = stream_run_id
2035
+ row = session.execute(
2036
+ SimpleStatement(
2037
+ "SELECT MAX(sequence) AS max_seq FROM signals WHERE stream_run_id = %s",
2038
+ consistency_level=self.read_consistency,
2039
+ ),
2040
+ (run_key,),
2041
+ ).one()
2042
+
2043
+ seq = (row.max_seq + 1) if row and row.max_seq is not None else 0
2044
+
2045
+ session.execute(
2046
+ SimpleStatement(
2047
+ "INSERT INTO signals (stream_run_id, sequence, signal_id, stream_id, signal_type, payload, "
2048
+ "published_at, source_run_id, metadata) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
2049
+ consistency_level=self.write_consistency,
2050
+ ),
2051
+ (
2052
+ run_key,
2053
+ seq,
2054
+ signal_id,
2055
+ stream_id,
2056
+ signal_type,
2057
+ json.dumps(payload),
2058
+ now,
2059
+ source_run_id,
2060
+ json.dumps(metadata or {}),
2061
+ ),
2062
+ )
2063
+ return seq
2064
+
2065
+ async def get_signals(
2066
+ self,
2067
+ stream_id: str,
2068
+ after_sequence: int = 0,
2069
+ limit: int = 100,
2070
+ ) -> list[dict]:
2071
+ """Get signals from a stream after a given sequence number."""
2072
+ session = self._ensure_connected()
2073
+
2074
+ rows = session.execute(
2075
+ SimpleStatement(
2076
+ "SELECT signal_id, stream_id, signal_type, payload, published_at, "
2077
+ "sequence, source_run_id, stream_run_id, metadata FROM signals "
2078
+ "WHERE stream_id = %s AND sequence >= %s LIMIT %s ALLOW FILTERING",
2079
+ consistency_level=self.read_consistency,
2080
+ ),
2081
+ (stream_id, after_sequence, limit),
2082
+ )
2083
+
2084
+ return [
2085
+ {
2086
+ "signal_id": row.signal_id,
2087
+ "stream_id": row.stream_id,
2088
+ "signal_type": row.signal_type,
2089
+ "payload": json.loads(row.payload) if row.payload else {},
2090
+ "published_at": row.published_at.isoformat() if row.published_at else None,
2091
+ "sequence": row.sequence,
2092
+ "source_run_id": row.source_run_id,
2093
+ "stream_run_id": row.stream_run_id,
2094
+ "metadata": json.loads(row.metadata) if row.metadata else {},
2095
+ }
2096
+ for row in rows
2097
+ ]
2098
+
2099
+ async def register_stream_subscription(
2100
+ self,
2101
+ stream_id: str,
2102
+ step_run_id: str,
2103
+ signal_types: list[str],
2104
+ ) -> None:
2105
+ """Register a stream step's subscription to signal types."""
2106
+ session = self._ensure_connected()
2107
+ now = datetime.now(UTC)
2108
+
2109
+ session.execute(
2110
+ SimpleStatement(
2111
+ "INSERT INTO stream_subscriptions (stream_id, step_run_id, signal_types, "
2112
+ "status, created_at) VALUES (%s, %s, %s, %s, %s)",
2113
+ consistency_level=self.write_consistency,
2114
+ ),
2115
+ (stream_id, step_run_id, json.dumps(signal_types), "waiting", now),
2116
+ )
2117
+
2118
+ async def get_waiting_steps(
2119
+ self,
2120
+ stream_id: str,
2121
+ signal_type: str,
2122
+ ) -> list[dict]:
2123
+ """Get step_run_ids waiting for a specific signal type on a stream."""
2124
+ session = self._ensure_connected()
2125
+
2126
+ rows = session.execute(
2127
+ SimpleStatement(
2128
+ "SELECT stream_id, step_run_id, signal_types, status, created_at "
2129
+ "FROM stream_subscriptions WHERE stream_id = %s",
2130
+ consistency_level=self.read_consistency,
2131
+ ),
2132
+ (stream_id,),
2133
+ )
2134
+
2135
+ result = []
2136
+ for row in rows:
2137
+ if row.status != "waiting":
2138
+ continue
2139
+ types = json.loads(row.signal_types) if row.signal_types else []
2140
+ if signal_type in types:
2141
+ result.append(
2142
+ {
2143
+ "stream_id": row.stream_id,
2144
+ "step_run_id": row.step_run_id,
2145
+ "signal_types": types,
2146
+ "status": row.status,
2147
+ "created_at": row.created_at.isoformat() if row.created_at else None,
2148
+ }
2149
+ )
2150
+ return result
2151
+
2152
+ async def update_subscription_status(
2153
+ self,
2154
+ stream_id: str,
2155
+ step_run_id: str,
2156
+ status: str,
2157
+ ) -> None:
2158
+ """Update a subscription's status."""
2159
+ session = self._ensure_connected()
2160
+
2161
+ session.execute(
2162
+ SimpleStatement(
2163
+ "UPDATE stream_subscriptions SET status = %s "
2164
+ "WHERE stream_id = %s AND step_run_id = %s",
2165
+ consistency_level=self.write_consistency,
2166
+ ),
2167
+ (status, stream_id, step_run_id),
2168
+ )
2169
+
2170
+ async def acknowledge_signal(
2171
+ self,
2172
+ signal_id: str,
2173
+ step_run_id: str,
2174
+ ) -> None:
2175
+ """Acknowledge that a signal has been processed by a step."""
2176
+ session = self._ensure_connected()
2177
+ now = datetime.now(UTC)
2178
+
2179
+ session.execute(
2180
+ SimpleStatement(
2181
+ "INSERT INTO signal_acknowledgments (signal_id, step_run_id, acknowledged_at) "
2182
+ "VALUES (%s, %s, %s)",
2183
+ consistency_level=self.write_consistency,
2184
+ ),
2185
+ (signal_id, step_run_id, now),
2186
+ )
2187
+
2188
+ async def get_pending_signals(
2189
+ self,
2190
+ stream_id: str,
2191
+ step_run_id: str,
2192
+ ) -> list[dict]:
2193
+ """Get signals that arrived for a step but haven't been acknowledged."""
2194
+ session = self._ensure_connected()
2195
+
2196
+ # Get subscription
2197
+ sub_row = session.execute(
2198
+ SimpleStatement(
2199
+ "SELECT signal_types FROM stream_subscriptions "
2200
+ "WHERE stream_id = %s AND step_run_id = %s",
2201
+ consistency_level=self.read_consistency,
2202
+ ),
2203
+ (stream_id, step_run_id),
2204
+ ).one()
2205
+
2206
+ if not sub_row:
2207
+ return []
2208
+
2209
+ signal_types = json.loads(sub_row.signal_types) if sub_row.signal_types else []
2210
+ if not signal_types:
2211
+ return []
2212
+
2213
+ # Get all signals for the stream
2214
+ signals = session.execute(
2215
+ SimpleStatement(
2216
+ "SELECT signal_id, stream_id, signal_type, payload, published_at, "
2217
+ "sequence, source_run_id, metadata FROM signals "
2218
+ "WHERE stream_id = %s ORDER BY sequence ASC",
2219
+ consistency_level=self.read_consistency,
2220
+ ),
2221
+ (stream_id,),
2222
+ )
2223
+
2224
+ pending = []
2225
+ for sig in signals:
2226
+ if sig.signal_type not in signal_types:
2227
+ continue
2228
+
2229
+ # Check if acknowledged
2230
+ ack = session.execute(
2231
+ SimpleStatement(
2232
+ "SELECT signal_id FROM signal_acknowledgments "
2233
+ "WHERE signal_id = %s AND step_run_id = %s",
2234
+ consistency_level=self.read_consistency,
2235
+ ),
2236
+ (sig.signal_id, step_run_id),
2237
+ ).one()
2238
+
2239
+ if ack is None:
2240
+ pending.append(
2241
+ {
2242
+ "signal_id": sig.signal_id,
2243
+ "stream_id": sig.stream_id,
2244
+ "signal_type": sig.signal_type,
2245
+ "payload": json.loads(sig.payload) if sig.payload else {},
2246
+ "published_at": sig.published_at.isoformat() if sig.published_at else None,
2247
+ "sequence": sig.sequence,
2248
+ "source_run_id": sig.source_run_id,
2249
+ "metadata": json.loads(sig.metadata) if sig.metadata else {},
2250
+ }
2251
+ )
2252
+
2253
+ return pending
@@ -79,6 +79,37 @@ class CitusMigrationRunner(PostgresMigrationRunner):
79
79
  """)
80
80
  # NOTE: No UNIQUE constraints added here — Citus requires distribution
81
81
  # column in every unique constraint, which V2 doesn't add.
82
+ elif migration.version == 3:
83
+ # V3: Restructure signals table — PK changes to (stream_id, stream_run_id, sequence).
84
+ # Must undistribute before dropping on Citus.
85
+ await conn.execute("DROP TABLE IF EXISTS signal_acknowledgments")
86
+ await conn.execute("DROP TABLE IF EXISTS signals")
87
+ await conn.execute("""
88
+ CREATE TABLE signals (
89
+ stream_run_id TEXT NOT NULL,
90
+ sequence INTEGER NOT NULL,
91
+ signal_id TEXT NOT NULL,
92
+ stream_id TEXT NOT NULL,
93
+ signal_type TEXT NOT NULL,
94
+ payload JSONB NOT NULL DEFAULT '{}',
95
+ published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
96
+ source_run_id TEXT,
97
+ metadata JSONB DEFAULT '{}',
98
+ PRIMARY KEY (stream_id, stream_run_id, sequence)
99
+ )
100
+ """)
101
+ await conn.execute("CREATE INDEX idx_signals_signal_id ON signals(signal_id)")
102
+ await conn.execute("""
103
+ CREATE TABLE signal_acknowledgments (
104
+ signal_id TEXT NOT NULL,
105
+ step_run_id TEXT NOT NULL,
106
+ acknowledged_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
107
+ PRIMARY KEY (signal_id, step_run_id)
108
+ )
109
+ """)
110
+ # Re-distribute the new tables
111
+ await conn.execute("SELECT create_distributed_table('signals', 'stream_run_id')")
112
+ await conn.execute("SELECT create_reference_table('signal_acknowledgments')")
82
113
  elif migration.up_func:
83
114
  await migration.up_func(conn)
84
115
  elif migration.up_sql and migration.up_sql != "SELECT 1":
@@ -337,6 +368,68 @@ class CitusStorageBackend(PostgresStorageBackend):
337
368
  )
338
369
  """)
339
370
 
371
+ # streams — reference table (independent, keyed by stream_id)
372
+ await conn.execute("""
373
+ CREATE TABLE IF NOT EXISTS streams (
374
+ stream_id TEXT PRIMARY KEY,
375
+ status TEXT NOT NULL DEFAULT 'active',
376
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
377
+ metadata JSONB DEFAULT '{}'
378
+ )
379
+ """)
380
+
381
+ # signals — distributed on stream_run_id
382
+ # Differences vs postgres:
383
+ # - PK: (stream_id, stream_run_id, sequence) — distribution column is stream_run_id
384
+ # - No FK constraints (cross-shard FKs unsupported)
385
+ await conn.execute("""
386
+ CREATE TABLE IF NOT EXISTS signals (
387
+ stream_run_id TEXT NOT NULL,
388
+ sequence INTEGER NOT NULL,
389
+ signal_id TEXT NOT NULL,
390
+ stream_id TEXT NOT NULL,
391
+ signal_type TEXT NOT NULL,
392
+ payload JSONB NOT NULL DEFAULT '{}',
393
+ published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
394
+ source_run_id TEXT,
395
+ metadata JSONB DEFAULT '{}',
396
+ PRIMARY KEY (stream_id, stream_run_id, sequence)
397
+ )
398
+ """)
399
+ await conn.execute(
400
+ "CREATE INDEX IF NOT EXISTS idx_signals_signal_id ON signals(signal_id)"
401
+ )
402
+
403
+ # stream_subscriptions — distributed on stream_id, co-located with signals
404
+ # Differences vs postgres:
405
+ # - PK: (stream_id, step_run_id) to include distribution column
406
+ # - No FK constraints (cross-shard FKs unsupported)
407
+ await conn.execute("""
408
+ CREATE TABLE IF NOT EXISTS stream_subscriptions (
409
+ stream_id TEXT NOT NULL,
410
+ step_run_id TEXT NOT NULL,
411
+ signal_types JSONB NOT NULL DEFAULT '[]',
412
+ status TEXT NOT NULL DEFAULT 'waiting',
413
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
414
+ PRIMARY KEY (stream_id, step_run_id)
415
+ )
416
+ """)
417
+ await conn.execute(
418
+ "CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON stream_subscriptions(stream_id, status)"
419
+ )
420
+
421
+ # signal_acknowledgments — reference table (needs cross-shard joins)
422
+ # Differences vs postgres:
423
+ # - No FK constraints (cross-shard FKs unsupported)
424
+ await conn.execute("""
425
+ CREATE TABLE IF NOT EXISTS signal_acknowledgments (
426
+ signal_id TEXT NOT NULL,
427
+ step_run_id TEXT NOT NULL,
428
+ acknowledged_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
429
+ PRIMARY KEY (signal_id, step_run_id)
430
+ )
431
+ """)
432
+
340
433
  # Step 4: Distribute tables (idempotent — already-distributed tables are skipped)
341
434
  await self._distribute_tables(pool)
342
435
 
@@ -385,3 +478,21 @@ class CitusStorageBackend(PostgresStorageBackend):
385
478
  # checkpoints: reference table (keyed by step_run_id, can't co-locate with run_id)
386
479
  if "checkpoints" not in distributed:
387
480
  await conn.execute("SELECT create_reference_table('checkpoints')")
481
+
482
+ # streams: reference table (independent, small)
483
+ if "streams" not in distributed:
484
+ await conn.execute("SELECT create_reference_table('streams')")
485
+
486
+ # signals: distributed on stream_run_id
487
+ if "signals" not in distributed:
488
+ await conn.execute("SELECT create_distributed_table('signals', 'stream_run_id')")
489
+
490
+ # stream_subscriptions: distributed on stream_id (independent from signals)
491
+ if "stream_subscriptions" not in distributed:
492
+ await conn.execute(
493
+ "SELECT create_distributed_table('stream_subscriptions', 'stream_id')"
494
+ )
495
+
496
+ # signal_acknowledgments: reference table (needs cross-shard joins)
497
+ if "signal_acknowledgments" not in distributed:
498
+ await conn.execute("SELECT create_reference_table('signal_acknowledgments')")