pyworkflow-engine 0.1.36__tar.gz → 0.1.37__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 (210) hide show
  1. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/PKG-INFO +1 -1
  2. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyproject.toml +1 -1
  3. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/__init__.py +1 -1
  4. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/celery/app.py +9 -2
  5. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/celery/tasks.py +42 -0
  6. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/config.py +22 -3
  7. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/__init__.py +7 -0
  8. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/base.py +16 -0
  9. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/cassandra.py +66 -0
  10. pyworkflow_engine-0.1.37/pyworkflow/storage/citus.py +370 -0
  11. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/config.py +23 -2
  12. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/dynamodb.py +70 -0
  13. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/file.py +37 -0
  14. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/memory.py +31 -0
  15. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/mysql.py +25 -0
  16. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/postgres.py +22 -0
  17. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/sqlite.py +22 -0
  18. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow_engine.egg-info/SOURCES.txt +3 -0
  19. pyworkflow_engine-0.1.37/tests/unit/backends/test_citus_storage.py +286 -0
  20. pyworkflow_engine-0.1.37/tests/unit/test_retention.py +245 -0
  21. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/CLAUDE.md +0 -0
  22. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/DISTRIBUTED.md +0 -0
  23. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/LICENSE +0 -0
  24. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/MANIFEST.in +0 -0
  25. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/README.md +0 -0
  26. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/RELEASING.md +0 -0
  27. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/architecture.md +0 -0
  28. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/concepts/cancellation.mdx +0 -0
  29. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/concepts/continue-as-new.mdx +0 -0
  30. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/concepts/events.mdx +0 -0
  31. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/concepts/fault-tolerance.mdx +0 -0
  32. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/concepts/hooks.mdx +0 -0
  33. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/concepts/limitations.mdx +0 -0
  34. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/concepts/schedules.mdx +0 -0
  35. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/concepts/sleep.mdx +0 -0
  36. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/concepts/step-context.mdx +0 -0
  37. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/concepts/steps.mdx +0 -0
  38. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/concepts/workflows.mdx +0 -0
  39. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/conventions.md +0 -0
  40. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/guides/brokers.mdx +0 -0
  41. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/guides/cli.mdx +0 -0
  42. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/guides/configuration.mdx +0 -0
  43. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/harness-gaps.md +0 -0
  44. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/introduction.mdx +0 -0
  45. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/layers.md +0 -0
  46. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/docs/quickstart.mdx +0 -0
  47. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/__init__.py +0 -0
  48. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/__init__.py +0 -0
  49. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/docker-compose.yml +0 -0
  50. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/pyworkflow.config.yaml +0 -0
  51. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/__init__.py +0 -0
  52. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/basic.py +0 -0
  53. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/batch_processing.py +0 -0
  54. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/cancellation.py +0 -0
  55. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/child_workflow_from_step.py +0 -0
  56. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/child_workflow_patterns.py +0 -0
  57. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/child_workflows.py +0 -0
  58. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/continue_as_new.py +0 -0
  59. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/fault_tolerance.py +0 -0
  60. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/hooks.py +0 -0
  61. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/idempotency.py +0 -0
  62. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/long_running.py +0 -0
  63. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/retries.py +0 -0
  64. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/schedules.py +0 -0
  65. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/sleep_in_step.py +0 -0
  66. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/durable/workflows/step_context.py +0 -0
  67. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/transient/01_basic_workflow.py +0 -0
  68. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/transient/02_fault_tolerance.py +0 -0
  69. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/transient/__init__.py +0 -0
  70. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/celery/transient/pyworkflow.config.yaml +0 -0
  71. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/__init__.py +0 -0
  72. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/01_basic_workflow.py +0 -0
  73. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/02_file_storage.py +0 -0
  74. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/03_retries.py +0 -0
  75. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/04_long_running.py +0 -0
  76. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/05_event_log.py +0 -0
  77. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/06_idempotency.py +0 -0
  78. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/07_hooks.py +0 -0
  79. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/08_cancellation.py +0 -0
  80. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/09_child_workflows.py +0 -0
  81. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/10_child_workflow_patterns.py +0 -0
  82. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/11_continue_as_new.py +0 -0
  83. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/12_schedules.py +0 -0
  84. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/13_step_context.py +0 -0
  85. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/14_child_workflow_from_step.py +0 -0
  86. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/durable/__init__.py +0 -0
  87. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/transient/01_quick_tasks.py +0 -0
  88. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/transient/02_retries.py +0 -0
  89. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/transient/03_sleep.py +0 -0
  90. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/examples/local/transient/__init__.py +0 -0
  91. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/aws/__init__.py +0 -0
  92. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/aws/context.py +0 -0
  93. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/aws/handler.py +0 -0
  94. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/aws/testing.py +0 -0
  95. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/celery/__init__.py +0 -0
  96. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/celery/loop.py +0 -0
  97. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/celery/scheduler.py +0 -0
  98. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/celery/singleton.py +0 -0
  99. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/__init__.py +0 -0
  100. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/__main__.py +0 -0
  101. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/commands/__init__.py +0 -0
  102. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/commands/hooks.py +0 -0
  103. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/commands/quickstart.py +0 -0
  104. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/commands/runs.py +0 -0
  105. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/commands/scheduler.py +0 -0
  106. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/commands/schedules.py +0 -0
  107. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/commands/setup.py +0 -0
  108. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/commands/worker.py +0 -0
  109. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/commands/workflows.py +0 -0
  110. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/output/__init__.py +0 -0
  111. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/output/formatters.py +0 -0
  112. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/output/styles.py +0 -0
  113. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/utils/__init__.py +0 -0
  114. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/utils/async_helpers.py +0 -0
  115. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/utils/config.py +0 -0
  116. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/utils/config_generator.py +0 -0
  117. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/utils/discovery.py +0 -0
  118. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/utils/docker_manager.py +0 -0
  119. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/utils/interactive.py +0 -0
  120. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/cli/utils/storage.py +0 -0
  121. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/context/__init__.py +0 -0
  122. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/context/aws.py +0 -0
  123. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/context/base.py +0 -0
  124. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/context/local.py +0 -0
  125. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/context/mock.py +0 -0
  126. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/context/step_context.py +0 -0
  127. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/core/__init__.py +0 -0
  128. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/core/exceptions.py +0 -0
  129. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/core/registry.py +0 -0
  130. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/core/scheduled.py +0 -0
  131. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/core/step.py +0 -0
  132. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/core/validation.py +0 -0
  133. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/core/workflow.py +0 -0
  134. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/discovery.py +0 -0
  135. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/engine/__init__.py +0 -0
  136. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/engine/events.py +0 -0
  137. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/engine/executor.py +0 -0
  138. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/engine/replay.py +0 -0
  139. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/observability/__init__.py +0 -0
  140. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/observability/logging.py +0 -0
  141. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/primitives/__init__.py +0 -0
  142. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/primitives/child_handle.py +0 -0
  143. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/primitives/child_workflow.py +0 -0
  144. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/primitives/continue_as_new.py +0 -0
  145. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/primitives/define_hook.py +0 -0
  146. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/primitives/hooks.py +0 -0
  147. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/primitives/resume_hook.py +0 -0
  148. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/primitives/schedule.py +0 -0
  149. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/primitives/shield.py +0 -0
  150. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/primitives/sleep.py +0 -0
  151. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/runtime/__init__.py +0 -0
  152. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/runtime/base.py +0 -0
  153. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/runtime/celery.py +0 -0
  154. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/runtime/factory.py +0 -0
  155. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/runtime/local.py +0 -0
  156. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/scheduler/__init__.py +0 -0
  157. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/scheduler/local.py +0 -0
  158. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/serialization/__init__.py +0 -0
  159. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/serialization/decoder.py +0 -0
  160. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/serialization/encoder.py +0 -0
  161. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/migrations/__init__.py +0 -0
  162. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/migrations/base.py +0 -0
  163. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/storage/schemas.py +0 -0
  164. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/utils/__init__.py +0 -0
  165. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/utils/duration.py +0 -0
  166. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/utils/helpers.py +0 -0
  167. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/pyworkflow/utils/schedule.py +0 -0
  168. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/setup.cfg +0 -0
  169. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/integration/__init__.py +0 -0
  170. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/integration/test_cancellation.py +0 -0
  171. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/integration/test_cassandra_storage.py +0 -0
  172. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/integration/test_child_workflows.py +0 -0
  173. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/integration/test_continue_as_new.py +0 -0
  174. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/integration/test_dynamodb_storage.py +0 -0
  175. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/integration/test_fault_tolerance.py +0 -0
  176. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/integration/test_schedule_storage.py +0 -0
  177. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/integration/test_schema_migrations.py +0 -0
  178. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/integration/test_singleton.py +0 -0
  179. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/integration/test_workflow_suspended.py +0 -0
  180. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/__init__.py +0 -0
  181. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/backends/__init__.py +0 -0
  182. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/backends/test_cassandra_storage.py +0 -0
  183. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/backends/test_dynamodb_storage.py +0 -0
  184. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/backends/test_postgres_storage.py +0 -0
  185. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/backends/test_sqlite_storage.py +0 -0
  186. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/conftest.py +0 -0
  187. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/storage/__init__.py +0 -0
  188. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/storage/test_migrations.py +0 -0
  189. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_cancellation.py +0 -0
  190. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_child_workflows.py +0 -0
  191. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_cli_worker.py +0 -0
  192. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_continue_as_new.py +0 -0
  193. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_event_limits.py +0 -0
  194. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_executor.py +0 -0
  195. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_fault_tolerance.py +0 -0
  196. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_force_local.py +0 -0
  197. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_hooks.py +0 -0
  198. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_parent_run_id.py +0 -0
  199. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_primitives_from_steps.py +0 -0
  200. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_registry.py +0 -0
  201. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_replay.py +0 -0
  202. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_schedule_schemas.py +0 -0
  203. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_schedule_utils.py +0 -0
  204. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_scheduled_workflow.py +0 -0
  205. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_singleton.py +0 -0
  206. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_step.py +0 -0
  207. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_step_context.py +0 -0
  208. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_validation.py +0 -0
  209. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/tests/unit/test_workflow.py +0 -0
  210. {pyworkflow_engine-0.1.36 → pyworkflow_engine-0.1.37}/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.1.37
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.1.37"
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.1.37"
33
33
 
34
34
  # Configuration
35
35
  from pyworkflow.config import (
@@ -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",
@@ -2749,3 +2749,45 @@ async def _handle_continue_as_new_celery(
2749
2749
  )
2750
2750
 
2751
2751
  return new_run_id
2752
+
2753
+
2754
+ @celery_app.task(
2755
+ name="pyworkflow.run_data_retention",
2756
+ base=SingletonWorkflowTask,
2757
+ bind=True,
2758
+ queue="pyworkflow.default",
2759
+ release_lock_on_failure=True,
2760
+ lock_expiry=7200, # 2h safety net
2761
+ )
2762
+ def run_data_retention_task(self: SingletonWorkflowTask) -> dict[str, Any]:
2763
+ """
2764
+ Periodic task: delete workflow runs older than data_retention_days.
2765
+
2766
+ Singleton (only one instance runs at a time). Skips if data_retention_days
2767
+ is not configured.
2768
+ """
2769
+ from datetime import timedelta
2770
+
2771
+ from pyworkflow.config import get_config
2772
+ from pyworkflow.storage.config import storage_to_config
2773
+
2774
+ config = get_config()
2775
+ if config.data_retention_days is None:
2776
+ logger.debug("Data retention not configured; skipping.")
2777
+ return {"deleted": 0, "skipped": True}
2778
+
2779
+ storage = config.storage
2780
+ if storage is None:
2781
+ logger.warning("No storage configured; skipping data retention.")
2782
+ return {"deleted": 0, "skipped": True}
2783
+
2784
+ cutoff = datetime.now(UTC) - timedelta(days=config.data_retention_days)
2785
+ logger.info(
2786
+ "Running data retention: deleting runs updated before {}",
2787
+ cutoff.isoformat(),
2788
+ )
2789
+ storage_config = storage_to_config(storage)
2790
+ backend = _get_storage_backend(storage_config)
2791
+ count = run_async(backend.delete_old_runs(cutoff))
2792
+ logger.info("Data retention complete: deleted {} runs", count)
2793
+ return {"deleted": count, "skipped": False}
@@ -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
 
@@ -47,6 +47,12 @@ try:
47
47
  except ImportError:
48
48
  MySQLStorageBackend = None # type: ignore
49
49
 
50
+ # Citus distributed PostgreSQL backend - optional import (requires asyncpg + Citus extension)
51
+ try:
52
+ from pyworkflow.storage.citus import CitusStorageBackend
53
+ except ImportError:
54
+ CitusStorageBackend = None # type: ignore
55
+
50
56
  __all__ = [
51
57
  "StorageBackend",
52
58
  "FileStorageBackend",
@@ -56,6 +62,7 @@ __all__ = [
56
62
  "DynamoDBStorageBackend",
57
63
  "CassandraStorageBackend",
58
64
  "MySQLStorageBackend",
65
+ "CitusStorageBackend",
59
66
  "WorkflowRun",
60
67
  "StepExecution",
61
68
  "Hook",
@@ -700,6 +700,22 @@ class StorageBackend(ABC):
700
700
  """
701
701
  pass
702
702
 
703
+ @abstractmethod
704
+ async def delete_old_runs(self, older_than: datetime) -> int:
705
+ """
706
+ Delete workflow runs in terminal states last updated before `older_than`.
707
+
708
+ Terminal states: COMPLETED, FAILED, CANCELLED, CONTINUED_AS_NEW, INTERRUPTED.
709
+ Associated events, steps, hooks, and cancellation flags are deleted too.
710
+
711
+ Args:
712
+ older_than: Delete runs where updated_at < older_than
713
+
714
+ Returns:
715
+ Number of workflow runs deleted
716
+ """
717
+ pass
718
+
703
719
  async def health_check(self) -> bool:
704
720
  """
705
721
  Check if storage backend is healthy and accessible.
@@ -1709,6 +1709,72 @@ class CassandraStorageBackend(StorageBackend):
1709
1709
  schedule.updated_at = datetime.now(UTC)
1710
1710
  await self.update_schedule(schedule)
1711
1711
 
1712
+ async def delete_old_runs(self, older_than: datetime) -> int:
1713
+ """Delete terminal runs (and all related data) updated before older_than."""
1714
+ session = self._ensure_connected()
1715
+ terminal = ["completed", "failed", "cancelled", "continued_as_new", "interrupted"]
1716
+ count = 0
1717
+
1718
+ for status_val in terminal:
1719
+ rows = session.execute(
1720
+ SimpleStatement(
1721
+ "SELECT run_id FROM runs_by_status WHERE status = %s",
1722
+ consistency_level=self.read_consistency,
1723
+ ),
1724
+ (status_val,),
1725
+ )
1726
+ for row in rows:
1727
+ run_id = row.run_id
1728
+ # Fetch run to check updated_at
1729
+ run_row = session.execute(
1730
+ SimpleStatement(
1731
+ "SELECT updated_at FROM workflow_runs WHERE run_id = %s",
1732
+ consistency_level=self.read_consistency,
1733
+ ),
1734
+ (run_id,),
1735
+ ).one()
1736
+ if run_row is None or run_row.updated_at >= older_than:
1737
+ continue
1738
+ # Delete all related data
1739
+ session.execute(
1740
+ SimpleStatement(
1741
+ "DELETE FROM events WHERE run_id = %s",
1742
+ consistency_level=self.write_consistency,
1743
+ ),
1744
+ (run_id,),
1745
+ )
1746
+ session.execute(
1747
+ SimpleStatement(
1748
+ "DELETE FROM steps WHERE run_id = %s",
1749
+ consistency_level=self.write_consistency,
1750
+ ),
1751
+ (run_id,),
1752
+ )
1753
+ session.execute(
1754
+ SimpleStatement(
1755
+ "DELETE FROM hooks WHERE run_id = %s",
1756
+ consistency_level=self.write_consistency,
1757
+ ),
1758
+ (run_id,),
1759
+ )
1760
+ session.execute(
1761
+ SimpleStatement(
1762
+ "DELETE FROM cancellation_flags WHERE run_id = %s",
1763
+ consistency_level=self.write_consistency,
1764
+ ),
1765
+ (run_id,),
1766
+ )
1767
+ session.execute(
1768
+ SimpleStatement(
1769
+ "DELETE FROM workflow_runs WHERE run_id = %s",
1770
+ consistency_level=self.write_consistency,
1771
+ ),
1772
+ (run_id,),
1773
+ )
1774
+ count += 1
1775
+
1776
+ return count
1777
+
1712
1778
  # Helper methods for converting Cassandra rows to domain objects
1713
1779
 
1714
1780
  def _row_to_workflow_run(self, row: Any) -> WorkflowRun:
@@ -0,0 +1,370 @@
1
+ """
2
+ Citus distributed PostgreSQL storage backend.
3
+
4
+ Citus is a PostgreSQL extension that shards tables across multiple nodes for
5
+ horizontal scalability. This backend extends PostgresStorageBackend, reusing all
6
+ query logic while overriding schema initialization to create Citus-compatible
7
+ distributed tables.
8
+
9
+ Distribution strategy:
10
+ - All workflow tables are co-located on `run_id` so that all data for a given
11
+ workflow run lands on the same Citus shard.
12
+ - Schedules are distributed on `schedule_id` (independent of workflow runs).
13
+ - `schema_versions` becomes a reference table (replicated to all workers).
14
+
15
+ Citus constraints vs plain PostgreSQL:
16
+ - Primary keys and unique constraints MUST include the distribution column.
17
+ - Cross-shard foreign key constraints are unsupported.
18
+ - Global unique constraints on non-distribution columns cannot be enforced.
19
+
20
+ See: https://docs.citusdata.com/en/stable/develop/api.html
21
+ """
22
+
23
+ import asyncpg
24
+
25
+ from pyworkflow.storage.migrations import Migration
26
+ from pyworkflow.storage.postgres import PostgresMigrationRunner, PostgresStorageBackend
27
+
28
+
29
+ class CitusMigrationRunner(PostgresMigrationRunner):
30
+ """
31
+ Citus-specific migration runner.
32
+
33
+ Extends PostgresMigrationRunner to handle Citus schema constraints.
34
+ V2 migration: same step_id backfill as PostgreSQL, but skips creating
35
+ unique constraints that would conflict with Citus distribution requirements.
36
+ """
37
+
38
+ async def apply_migration(self, migration: Migration) -> None:
39
+ """Apply a migration with Citus-specific handling."""
40
+ from datetime import UTC, datetime
41
+
42
+ async with self._pool.acquire() as conn, conn.transaction():
43
+ if migration.version == 2:
44
+ # V2: Add step_id column to events table
45
+ # Check if events table exists (fresh databases won't have it yet)
46
+ table_exists = await conn.fetchrow("""
47
+ SELECT EXISTS (
48
+ SELECT FROM information_schema.tables
49
+ WHERE table_name = 'events'
50
+ ) as exists
51
+ """)
52
+
53
+ if table_exists and table_exists["exists"]:
54
+ # Use IF NOT EXISTS for idempotency
55
+ await conn.execute("""
56
+ DO $$
57
+ BEGIN
58
+ IF NOT EXISTS (
59
+ SELECT 1 FROM information_schema.columns
60
+ WHERE table_name = 'events' AND column_name = 'step_id'
61
+ ) THEN
62
+ ALTER TABLE events ADD COLUMN step_id TEXT;
63
+ END IF;
64
+ END $$
65
+ """)
66
+
67
+ # Create index for optimized has_event() queries
68
+ await conn.execute("""
69
+ CREATE INDEX IF NOT EXISTS idx_events_run_id_step_id_type
70
+ ON events(run_id, step_id, type)
71
+ """)
72
+
73
+ # Backfill step_id from JSON data
74
+ await conn.execute("""
75
+ UPDATE events
76
+ SET step_id = (data::jsonb)->>'step_id'
77
+ WHERE step_id IS NULL
78
+ AND (data::jsonb)->>'step_id' IS NOT NULL
79
+ """)
80
+ # NOTE: No UNIQUE constraints added here — Citus requires distribution
81
+ # column in every unique constraint, which V2 doesn't add.
82
+ elif migration.up_func:
83
+ await migration.up_func(conn)
84
+ elif migration.up_sql and migration.up_sql != "SELECT 1":
85
+ await conn.execute(migration.up_sql)
86
+
87
+ # Record the migration
88
+ await conn.execute(
89
+ """
90
+ INSERT INTO schema_versions (version, applied_at, description)
91
+ VALUES ($1, $2, $3)
92
+ """,
93
+ migration.version,
94
+ datetime.now(UTC),
95
+ migration.description,
96
+ )
97
+
98
+
99
+ class CitusStorageBackend(PostgresStorageBackend):
100
+ """
101
+ Citus distributed PostgreSQL storage backend.
102
+
103
+ Extends PostgresStorageBackend with Citus-specific schema initialization.
104
+ All query logic is inherited unchanged — only DDL differs to satisfy Citus's
105
+ distribution column requirements.
106
+
107
+ Tables are sharded as follows:
108
+ - workflow_runs, events, steps, hooks, cancellation_flags: distributed on run_id
109
+ - schedules: distributed on schedule_id
110
+ - schema_versions: reference table (replicated to all workers)
111
+
112
+ Requirements:
113
+ - PostgreSQL with the Citus extension installed and loaded
114
+ - The calling database user must have permission to call Citus functions
115
+
116
+ Usage:
117
+ backend = CitusStorageBackend(
118
+ host="citus-coordinator",
119
+ database="pyworkflow",
120
+ )
121
+ await backend.connect()
122
+ """
123
+
124
+ async def _initialize_schema(self) -> None:
125
+ """
126
+ Create Citus-compatible schema and distribute tables.
127
+
128
+ Steps:
129
+ 1. Verify Citus extension is available.
130
+ 2. Create tables with Citus-compatible PKs/constraints (no cross-shard FKs,
131
+ no cross-shard UNIQUE constraints, composite PKs include distribution col).
132
+ 3. Create indexes.
133
+ 4. Distribute tables (idempotent — already-distributed tables are skipped).
134
+ 5. Run migrations via CitusMigrationRunner.
135
+ """
136
+ pool = await self._get_pool()
137
+
138
+ # Step 1: Verify Citus extension is present
139
+ async with pool.acquire() as conn:
140
+ try:
141
+ await conn.fetchval("SELECT citus_version()")
142
+ except asyncpg.UndefinedFunctionError:
143
+ raise RuntimeError(
144
+ "Citus extension is not available on this PostgreSQL server. "
145
+ "Install Citus and run `CREATE EXTENSION citus;` before using "
146
+ "CitusStorageBackend. See: https://docs.citusdata.com/en/stable/installation/"
147
+ )
148
+
149
+ # Step 2 & 3: Create tables and indexes with Citus-compatible DDL
150
+ async with pool.acquire() as conn:
151
+ # schema_versions — created first so CitusMigrationRunner can use it
152
+ await conn.execute("""
153
+ CREATE TABLE IF NOT EXISTS schema_versions (
154
+ version INTEGER PRIMARY KEY,
155
+ applied_at TIMESTAMPTZ NOT NULL,
156
+ description TEXT
157
+ )
158
+ """)
159
+
160
+ # workflow_runs — distributed on run_id
161
+ # Differences vs postgres:
162
+ # - DROP FK on parent_run_id (self-referential FK can't be enforced cross-shard)
163
+ # - UNIQUE INDEX on idempotency_key → plain INDEX (global uniqueness unenforceable)
164
+ await conn.execute("""
165
+ CREATE TABLE IF NOT EXISTS workflow_runs (
166
+ run_id TEXT PRIMARY KEY,
167
+ workflow_name TEXT NOT NULL,
168
+ status TEXT NOT NULL,
169
+ created_at TIMESTAMPTZ NOT NULL,
170
+ updated_at TIMESTAMPTZ NOT NULL,
171
+ started_at TIMESTAMPTZ,
172
+ completed_at TIMESTAMPTZ,
173
+ input_args TEXT NOT NULL DEFAULT '[]',
174
+ input_kwargs TEXT NOT NULL DEFAULT '{}',
175
+ result TEXT,
176
+ error TEXT,
177
+ idempotency_key TEXT,
178
+ max_duration TEXT,
179
+ metadata TEXT DEFAULT '{}',
180
+ recovery_attempts INTEGER DEFAULT 0,
181
+ max_recovery_attempts INTEGER DEFAULT 3,
182
+ recover_on_worker_loss BOOLEAN DEFAULT TRUE,
183
+ parent_run_id TEXT,
184
+ nesting_depth INTEGER DEFAULT 0,
185
+ continued_from_run_id TEXT,
186
+ continued_to_run_id TEXT
187
+ )
188
+ """)
189
+
190
+ await conn.execute(
191
+ "CREATE INDEX IF NOT EXISTS idx_runs_status ON workflow_runs(status)"
192
+ )
193
+ await conn.execute(
194
+ "CREATE INDEX IF NOT EXISTS idx_runs_workflow_name ON workflow_runs(workflow_name)"
195
+ )
196
+ await conn.execute(
197
+ "CREATE INDEX IF NOT EXISTS idx_runs_created_at ON workflow_runs(created_at DESC)"
198
+ )
199
+ # Non-unique index: Citus cannot enforce global uniqueness on non-distribution columns
200
+ await conn.execute(
201
+ "CREATE INDEX IF NOT EXISTS idx_runs_idempotency_key ON workflow_runs(idempotency_key) WHERE idempotency_key IS NOT NULL"
202
+ )
203
+ await conn.execute(
204
+ "CREATE INDEX IF NOT EXISTS idx_runs_parent_run_id ON workflow_runs(parent_run_id)"
205
+ )
206
+
207
+ # events — distributed on run_id, co-located with workflow_runs
208
+ # Differences vs postgres:
209
+ # - PK: event_id → (run_id, event_id) to include distribution column
210
+ # - FK on run_id retained (co-located FK is supported by Citus)
211
+ await conn.execute("""
212
+ CREATE TABLE IF NOT EXISTS events (
213
+ event_id TEXT NOT NULL,
214
+ run_id TEXT NOT NULL,
215
+ sequence INTEGER NOT NULL,
216
+ type TEXT NOT NULL,
217
+ timestamp TIMESTAMPTZ NOT NULL,
218
+ data TEXT NOT NULL DEFAULT '{}',
219
+ step_id TEXT,
220
+ PRIMARY KEY (run_id, event_id)
221
+ )
222
+ """)
223
+
224
+ await conn.execute(
225
+ "CREATE INDEX IF NOT EXISTS idx_events_run_id_sequence ON events(run_id, sequence)"
226
+ )
227
+ await conn.execute(
228
+ "CREATE INDEX IF NOT EXISTS idx_events_run_id_type ON events(run_id, type)"
229
+ )
230
+ await conn.execute(
231
+ "CREATE INDEX IF NOT EXISTS idx_events_run_id_step_id_type ON events(run_id, step_id, type)"
232
+ )
233
+
234
+ # steps — distributed on run_id, co-located with workflow_runs
235
+ # Differences vs postgres:
236
+ # - PK: step_id → (run_id, step_id) to include distribution column
237
+ # - FK on run_id retained (co-located FK)
238
+ # - Extra index on step_id alone for get_step(step_id) scatter-gather queries
239
+ await conn.execute("""
240
+ CREATE TABLE IF NOT EXISTS steps (
241
+ step_id TEXT NOT NULL,
242
+ run_id TEXT NOT NULL,
243
+ step_name TEXT NOT NULL,
244
+ status TEXT NOT NULL,
245
+ created_at TIMESTAMPTZ NOT NULL,
246
+ started_at TIMESTAMPTZ,
247
+ completed_at TIMESTAMPTZ,
248
+ input_args TEXT NOT NULL DEFAULT '[]',
249
+ input_kwargs TEXT NOT NULL DEFAULT '{}',
250
+ result TEXT,
251
+ error TEXT,
252
+ retry_count INTEGER DEFAULT 0,
253
+ PRIMARY KEY (run_id, step_id)
254
+ )
255
+ """)
256
+
257
+ await conn.execute("CREATE INDEX IF NOT EXISTS idx_steps_run_id ON steps(run_id)")
258
+ # Shard-local index to speed up get_step(step_id) scatter-gather queries
259
+ await conn.execute("CREATE INDEX IF NOT EXISTS idx_steps_step_id ON steps(step_id)")
260
+
261
+ # hooks — distributed on run_id, co-located with workflow_runs
262
+ # Differences vs postgres:
263
+ # - UNIQUE on token → plain INDEX (token = run_id:hook_id, collisions impossible)
264
+ # - FK on run_id retained (co-located FK)
265
+ await conn.execute("""
266
+ CREATE TABLE IF NOT EXISTS hooks (
267
+ run_id TEXT NOT NULL,
268
+ hook_id TEXT NOT NULL,
269
+ token TEXT NOT NULL,
270
+ created_at TIMESTAMPTZ NOT NULL,
271
+ received_at TIMESTAMPTZ,
272
+ expires_at TIMESTAMPTZ,
273
+ status TEXT NOT NULL,
274
+ payload TEXT,
275
+ metadata TEXT DEFAULT '{}',
276
+ PRIMARY KEY (run_id, hook_id)
277
+ )
278
+ """)
279
+
280
+ await conn.execute("CREATE INDEX IF NOT EXISTS idx_hooks_token ON hooks(token)")
281
+ await conn.execute("CREATE INDEX IF NOT EXISTS idx_hooks_run_id ON hooks(run_id)")
282
+ await conn.execute("CREATE INDEX IF NOT EXISTS idx_hooks_status ON hooks(status)")
283
+
284
+ # schedules — distributed on schedule_id (independent of runs)
285
+ await conn.execute("""
286
+ CREATE TABLE IF NOT EXISTS schedules (
287
+ schedule_id TEXT PRIMARY KEY,
288
+ workflow_name TEXT NOT NULL,
289
+ spec TEXT NOT NULL,
290
+ spec_type TEXT NOT NULL,
291
+ timezone TEXT,
292
+ input_args TEXT NOT NULL DEFAULT '[]',
293
+ input_kwargs TEXT NOT NULL DEFAULT '{}',
294
+ status TEXT NOT NULL,
295
+ overlap_policy TEXT NOT NULL,
296
+ next_run_time TIMESTAMPTZ,
297
+ last_run_time TIMESTAMPTZ,
298
+ running_run_ids TEXT DEFAULT '[]',
299
+ metadata TEXT DEFAULT '{}',
300
+ created_at TIMESTAMPTZ NOT NULL,
301
+ updated_at TIMESTAMPTZ NOT NULL,
302
+ paused_at TIMESTAMPTZ,
303
+ deleted_at TIMESTAMPTZ
304
+ )
305
+ """)
306
+
307
+ await conn.execute(
308
+ "CREATE INDEX IF NOT EXISTS idx_schedules_status ON schedules(status)"
309
+ )
310
+ await conn.execute(
311
+ "CREATE INDEX IF NOT EXISTS idx_schedules_next_run_time ON schedules(next_run_time)"
312
+ )
313
+ await conn.execute(
314
+ "CREATE INDEX IF NOT EXISTS idx_schedules_workflow_name ON schedules(workflow_name)"
315
+ )
316
+
317
+ # cancellation_flags — distributed on run_id, co-located with workflow_runs
318
+ # Differences vs postgres:
319
+ # - FK on run_id dropped (cross-shard FK unsupported; co-location ensures correctness)
320
+ await conn.execute("""
321
+ CREATE TABLE IF NOT EXISTS cancellation_flags (
322
+ run_id TEXT PRIMARY KEY,
323
+ created_at TIMESTAMPTZ NOT NULL
324
+ )
325
+ """)
326
+
327
+ # Step 4: Distribute tables (idempotent — already-distributed tables are skipped)
328
+ await self._distribute_tables(pool)
329
+
330
+ # Step 5: Run migrations via Citus-aware runner
331
+ runner = CitusMigrationRunner(pool)
332
+ await runner.run_migrations()
333
+
334
+ async def _distribute_tables(self, pool: asyncpg.Pool) -> None:
335
+ """
336
+ Call Citus distribution functions for each table.
337
+
338
+ Uses pg_dist_partition to detect already-distributed tables so this
339
+ method is idempotent and safe to call on every connect().
340
+ """
341
+ async with pool.acquire() as conn:
342
+ # Fetch already-distributed table names using relname to avoid
343
+ # schema-prefix ambiguity (logicalrelid::text is search_path-dependent)
344
+ rows = await conn.fetch("""
345
+ SELECT c.relname AS tbl
346
+ FROM pg_dist_partition dp
347
+ JOIN pg_class c ON dp.logicalrelid = c.oid
348
+ """)
349
+ distributed = {row["tbl"] for row in rows}
350
+
351
+ # workflow_runs: anchor table, distribute first
352
+ if "workflow_runs" not in distributed:
353
+ await conn.execute("SELECT create_distributed_table('workflow_runs', 'run_id')")
354
+
355
+ # Co-located tables: must reference the same distribution column
356
+ colocated_run_id = ["events", "steps", "hooks", "cancellation_flags"]
357
+ for table in colocated_run_id:
358
+ if table not in distributed:
359
+ await conn.execute(
360
+ f"SELECT create_distributed_table('{table}', 'run_id', "
361
+ f"colocate_with => 'workflow_runs')"
362
+ )
363
+
364
+ # schedules: independent distribution
365
+ if "schedules" not in distributed:
366
+ await conn.execute("SELECT create_distributed_table('schedules', 'schedule_id')")
367
+
368
+ # schema_versions: reference table (replicated to all workers)
369
+ if "schema_versions" not in distributed:
370
+ await conn.execute("SELECT create_reference_table('schema_versions')")