pyworkflow-engine 0.3.4__tar.gz → 0.3.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 (243) hide show
  1. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/PKG-INFO +1 -1
  2. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyproject.toml +1 -1
  3. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/__init__.py +1 -1
  4. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/celery/app.py +42 -1
  5. pyworkflow_engine-0.3.6/pyworkflow/celery/reschedule.py +339 -0
  6. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow_engine.egg-info/SOURCES.txt +5 -0
  7. pyworkflow_engine-0.3.6/tests/integration/_reschedule_e2e_app.py +41 -0
  8. pyworkflow_engine-0.3.6/tests/integration/test_reschedule.py +133 -0
  9. pyworkflow_engine-0.3.6/tests/integration/test_reschedule_e2e.py +200 -0
  10. pyworkflow_engine-0.3.6/tests/unit/test_reschedule.py +323 -0
  11. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_singleton.py +58 -1
  12. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/CLAUDE.md +0 -0
  13. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/DISTRIBUTED.md +0 -0
  14. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/LICENSE +0 -0
  15. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/MANIFEST.in +0 -0
  16. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/README.md +0 -0
  17. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/RELEASING.md +0 -0
  18. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/docs/architecture.md +0 -0
  19. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/docs/concepts/cancellation.mdx +0 -0
  20. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/docs/concepts/continue-as-new.mdx +0 -0
  21. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/docs/concepts/events.mdx +0 -0
  22. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/docs/concepts/fault-tolerance.mdx +0 -0
  23. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/docs/concepts/hooks.mdx +0 -0
  24. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/docs/concepts/limitations.mdx +0 -0
  25. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/docs/concepts/schedules.mdx +0 -0
  26. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/docs/concepts/sleep.mdx +0 -0
  27. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/docs/concepts/step-context.mdx +0 -0
  28. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/docs/concepts/steps.mdx +0 -0
  29. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/docs/concepts/stream-steps.mdx +0 -0
  30. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/docs/concepts/stream-workflows.mdx +0 -0
  31. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/docs/concepts/workflows.mdx +0 -0
  32. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/docs/conventions.md +0 -0
  33. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/docs/guides/brokers.mdx +0 -0
  34. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/docs/guides/cli.mdx +0 -0
  35. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/docs/guides/configuration.mdx +0 -0
  36. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/docs/harness-gaps.md +0 -0
  37. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/docs/introduction.mdx +0 -0
  38. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/docs/layers.md +0 -0
  39. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/docs/quickstart.mdx +0 -0
  40. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/__init__.py +0 -0
  41. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/celery/__init__.py +0 -0
  42. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/celery/durable/docker-compose.yml +0 -0
  43. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/celery/durable/pyworkflow.config.yaml +0 -0
  44. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/celery/durable/workflows/__init__.py +0 -0
  45. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/celery/durable/workflows/basic.py +0 -0
  46. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/celery/durable/workflows/batch_processing.py +0 -0
  47. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/celery/durable/workflows/cancellation.py +0 -0
  48. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/celery/durable/workflows/child_workflow_from_step.py +0 -0
  49. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/celery/durable/workflows/child_workflow_patterns.py +0 -0
  50. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/celery/durable/workflows/child_workflows.py +0 -0
  51. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/celery/durable/workflows/continue_as_new.py +0 -0
  52. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/celery/durable/workflows/fault_tolerance.py +0 -0
  53. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/celery/durable/workflows/hooks.py +0 -0
  54. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/celery/durable/workflows/idempotency.py +0 -0
  55. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/celery/durable/workflows/long_running.py +0 -0
  56. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/celery/durable/workflows/retries.py +0 -0
  57. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/celery/durable/workflows/schedules.py +0 -0
  58. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/celery/durable/workflows/sleep_in_step.py +0 -0
  59. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/celery/durable/workflows/step_context.py +0 -0
  60. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/celery/transient/01_basic_workflow.py +0 -0
  61. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/celery/transient/02_fault_tolerance.py +0 -0
  62. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/celery/transient/__init__.py +0 -0
  63. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/celery/transient/pyworkflow.config.yaml +0 -0
  64. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/local/__init__.py +0 -0
  65. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/local/durable/01_basic_workflow.py +0 -0
  66. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/local/durable/02_file_storage.py +0 -0
  67. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/local/durable/03_retries.py +0 -0
  68. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/local/durable/04_long_running.py +0 -0
  69. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/local/durable/05_event_log.py +0 -0
  70. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/local/durable/06_idempotency.py +0 -0
  71. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/local/durable/07_hooks.py +0 -0
  72. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/local/durable/08_cancellation.py +0 -0
  73. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/local/durable/09_child_workflows.py +0 -0
  74. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/local/durable/10_child_workflow_patterns.py +0 -0
  75. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/local/durable/11_continue_as_new.py +0 -0
  76. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/local/durable/12_schedules.py +0 -0
  77. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/local/durable/13_step_context.py +0 -0
  78. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/local/durable/14_child_workflow_from_step.py +0 -0
  79. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/local/durable/__init__.py +0 -0
  80. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/local/transient/01_quick_tasks.py +0 -0
  81. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/local/transient/02_retries.py +0 -0
  82. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/local/transient/03_sleep.py +0 -0
  83. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/examples/local/transient/__init__.py +0 -0
  84. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/aws/__init__.py +0 -0
  85. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/aws/context.py +0 -0
  86. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/aws/handler.py +0 -0
  87. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/aws/testing.py +0 -0
  88. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/celery/__init__.py +0 -0
  89. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/celery/loop.py +0 -0
  90. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/celery/scheduler.py +0 -0
  91. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/celery/singleton.py +0 -0
  92. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/celery/tasks.py +0 -0
  93. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/cli/__init__.py +0 -0
  94. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/cli/__main__.py +0 -0
  95. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/cli/commands/__init__.py +0 -0
  96. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/cli/commands/hooks.py +0 -0
  97. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/cli/commands/quickstart.py +0 -0
  98. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/cli/commands/runs.py +0 -0
  99. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/cli/commands/scheduler.py +0 -0
  100. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/cli/commands/schedules.py +0 -0
  101. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/cli/commands/setup.py +0 -0
  102. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/cli/commands/worker.py +0 -0
  103. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/cli/commands/workflows.py +0 -0
  104. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/cli/output/__init__.py +0 -0
  105. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/cli/output/formatters.py +0 -0
  106. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/cli/output/styles.py +0 -0
  107. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/cli/utils/__init__.py +0 -0
  108. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/cli/utils/async_helpers.py +0 -0
  109. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/cli/utils/config.py +0 -0
  110. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/cli/utils/config_generator.py +0 -0
  111. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/cli/utils/discovery.py +0 -0
  112. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/cli/utils/docker_manager.py +0 -0
  113. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/cli/utils/interactive.py +0 -0
  114. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/cli/utils/storage.py +0 -0
  115. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/config.py +0 -0
  116. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/context/__init__.py +0 -0
  117. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/context/aws.py +0 -0
  118. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/context/base.py +0 -0
  119. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/context/local.py +0 -0
  120. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/context/mock.py +0 -0
  121. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/context/step_context.py +0 -0
  122. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/core/__init__.py +0 -0
  123. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/core/exceptions.py +0 -0
  124. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/core/registry.py +0 -0
  125. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/core/scheduled.py +0 -0
  126. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/core/step.py +0 -0
  127. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/core/validation.py +0 -0
  128. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/core/workflow.py +0 -0
  129. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/discovery.py +0 -0
  130. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/engine/__init__.py +0 -0
  131. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/engine/events.py +0 -0
  132. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/engine/executor.py +0 -0
  133. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/engine/replay.py +0 -0
  134. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/observability/__init__.py +0 -0
  135. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/observability/logging.py +0 -0
  136. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/primitives/__init__.py +0 -0
  137. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/primitives/child_handle.py +0 -0
  138. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/primitives/child_workflow.py +0 -0
  139. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/primitives/continue_as_new.py +0 -0
  140. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/primitives/define_hook.py +0 -0
  141. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/primitives/hooks.py +0 -0
  142. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/primitives/resume_hook.py +0 -0
  143. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/primitives/schedule.py +0 -0
  144. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/primitives/shield.py +0 -0
  145. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/primitives/sleep.py +0 -0
  146. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/primitives/step_checkpoint.py +0 -0
  147. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/primitives/step_hook.py +0 -0
  148. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/runtime/__init__.py +0 -0
  149. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/runtime/base.py +0 -0
  150. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/runtime/celery.py +0 -0
  151. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/runtime/factory.py +0 -0
  152. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/runtime/local.py +0 -0
  153. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/scheduler/__init__.py +0 -0
  154. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/scheduler/local.py +0 -0
  155. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/serialization/__init__.py +0 -0
  156. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/serialization/decoder.py +0 -0
  157. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/serialization/encoder.py +0 -0
  158. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/storage/__init__.py +0 -0
  159. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/storage/base.py +0 -0
  160. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/storage/cassandra.py +0 -0
  161. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/storage/citus.py +0 -0
  162. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/storage/config.py +0 -0
  163. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/storage/dynamodb.py +0 -0
  164. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/storage/file.py +0 -0
  165. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/storage/memory.py +0 -0
  166. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/storage/migrations/__init__.py +0 -0
  167. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/storage/migrations/base.py +0 -0
  168. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/storage/mysql.py +0 -0
  169. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/storage/postgres.py +0 -0
  170. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/storage/schemas.py +0 -0
  171. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/storage/sqlite.py +0 -0
  172. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/streams/__init__.py +0 -0
  173. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/streams/checkpoint.py +0 -0
  174. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/streams/consumer.py +0 -0
  175. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/streams/context.py +0 -0
  176. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/streams/decorator.py +0 -0
  177. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/streams/dispatcher.py +0 -0
  178. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/streams/emit.py +0 -0
  179. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/streams/registry.py +0 -0
  180. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/streams/runtime.py +0 -0
  181. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/streams/signal.py +0 -0
  182. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/streams/step_context.py +0 -0
  183. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/tracing/__init__.py +0 -0
  184. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/tracing/base.py +0 -0
  185. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/tracing/factory.py +0 -0
  186. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/tracing/langfuse.py +0 -0
  187. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/tracing/types.py +0 -0
  188. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/utils/__init__.py +0 -0
  189. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/utils/duration.py +0 -0
  190. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/utils/helpers.py +0 -0
  191. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/pyworkflow/utils/schedule.py +0 -0
  192. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/setup.cfg +0 -0
  193. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/integration/__init__.py +0 -0
  194. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/integration/test_cancellation.py +0 -0
  195. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/integration/test_cassandra_storage.py +0 -0
  196. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/integration/test_child_workflows.py +0 -0
  197. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/integration/test_continue_as_new.py +0 -0
  198. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/integration/test_dynamodb_storage.py +0 -0
  199. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/integration/test_fault_tolerance.py +0 -0
  200. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/integration/test_list_runs_pagination.py +0 -0
  201. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/integration/test_schedule_storage.py +0 -0
  202. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/integration/test_schema_migrations.py +0 -0
  203. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/integration/test_singleton.py +0 -0
  204. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/integration/test_stream_e2e.py +0 -0
  205. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/integration/test_workflow_suspended.py +0 -0
  206. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/__init__.py +0 -0
  207. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/backends/__init__.py +0 -0
  208. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/backends/test_cassandra_storage.py +0 -0
  209. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/backends/test_citus_storage.py +0 -0
  210. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/backends/test_dynamodb_storage.py +0 -0
  211. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/backends/test_postgres_storage.py +0 -0
  212. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/backends/test_sqlite_storage.py +0 -0
  213. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/conftest.py +0 -0
  214. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/storage/__init__.py +0 -0
  215. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/storage/test_migrations.py +0 -0
  216. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_cancellation.py +0 -0
  217. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_child_workflows.py +0 -0
  218. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_cli_worker.py +0 -0
  219. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_continue_as_new.py +0 -0
  220. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_emit.py +0 -0
  221. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_event_limits.py +0 -0
  222. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_executor.py +0 -0
  223. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_fault_tolerance.py +0 -0
  224. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_force_local.py +0 -0
  225. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_hooks.py +0 -0
  226. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_parent_run_id.py +0 -0
  227. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_primitives_from_steps.py +0 -0
  228. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_registry.py +0 -0
  229. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_replay.py +0 -0
  230. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_retention.py +0 -0
  231. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_schedule_schemas.py +0 -0
  232. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_schedule_utils.py +0 -0
  233. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_scheduled_workflow.py +0 -0
  234. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_signal.py +0 -0
  235. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_step.py +0 -0
  236. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_step_checkpoint.py +0 -0
  237. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_step_context.py +0 -0
  238. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_step_hook.py +0 -0
  239. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_stream_storage.py +0 -0
  240. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_stream_workflow.py +0 -0
  241. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_validation.py +0 -0
  242. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.6}/tests/unit/test_workflow.py +0 -0
  243. {pyworkflow_engine-0.3.4 → pyworkflow_engine-0.3.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.3.4
3
+ Version: 0.3.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.3.4"
10
+ version = "0.3.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.3.4"
32
+ __version__ = "0.3.6"
33
33
 
34
34
  # Configuration
35
35
  from pyworkflow.config import (
@@ -12,12 +12,19 @@ warnings in Celery logs. This is a known compatibility issue between Python 3.13
12
12
  garbage collector and Celery's saferepr module. It does not affect functionality.
13
13
  """
14
14
 
15
+ import contextlib
15
16
  import os
16
17
  from datetime import timedelta
17
18
  from typing import Any
18
19
 
19
20
  from celery import Celery
20
- from celery.signals import worker_init, worker_process_init, worker_shutdown
21
+ from celery.signals import (
22
+ task_postrun,
23
+ task_prerun,
24
+ worker_init,
25
+ worker_process_init,
26
+ worker_shutdown,
27
+ )
21
28
  from kombu import Exchange, Queue
22
29
 
23
30
  from pyworkflow.observability.logging import configure_logging
@@ -281,6 +288,9 @@ def create_celery_app(
281
288
  PYWORKFLOW_CELERY_SENTINEL_MASTER: Sentinel master name (used if sentinel_master_name param not provided)
282
289
  PYWORKFLOW_WORKER_MAX_MEMORY: Max memory per worker child (KB) (used if worker_max_memory_per_child param not provided)
283
290
  PYWORKFLOW_WORKER_MAX_TASKS: Max tasks per worker child (used if worker_max_tasks_per_child param not provided)
291
+ PYWORKFLOW_RESCHEDULE_ON_SIGTERM: Re-enqueue in-flight tasks immediately when a worker is shut down
292
+ (SIGTERM / spot reclaim), instead of waiting out the broker visibility_timeout. Default on; set to
293
+ "0"/"false"/"no" to disable. Implemented as a consumer bootstep; see pyworkflow/celery/reschedule.py.
284
294
 
285
295
  Examples:
286
296
  # Default configuration (uses env vars if set, otherwise localhost Redis)
@@ -471,6 +481,14 @@ def create_celery_app(
471
481
  # to ensure proper initialization AFTER process forking.
472
482
  # See on_worker_init() and on_worker_process_init() below.
473
483
 
484
+ # Re-enqueue in-flight tasks on worker shutdown (SIGTERM / spot reclaim) so a
485
+ # live worker resumes them within seconds instead of waiting out the broker
486
+ # visibility_timeout. Hooked as a consumer bootstep so it fires exactly as the
487
+ # consumer is torn down (event-based). See pyworkflow/celery/reschedule.py.
488
+ from pyworkflow.celery.reschedule import RescheduleConsumerStep
489
+
490
+ app.steps["consumer"].add(RescheduleConsumerStep)
491
+
474
492
  # Auto-discover workflows from environment variable or configured modules
475
493
  discover_workflows()
476
494
 
@@ -555,6 +573,29 @@ def on_worker_shutdown(**kwargs):
555
573
  close_worker_loop()
556
574
 
557
575
 
576
+ @task_prerun.connect
577
+ def on_task_prerun(task_id=None, task=None, args=None, kwargs=None, **_):
578
+ """
579
+ Track the task now executing in this child process so it can be re-enqueued
580
+ if the worker is shut down (SIGTERM) before the task finishes.
581
+
582
+ Wrapped defensively: tracking must never interfere with task execution.
583
+ """
584
+ from pyworkflow.celery.reschedule import track_task_start
585
+
586
+ with contextlib.suppress(Exception):
587
+ track_task_start(task, task_id, args, kwargs)
588
+
589
+
590
+ @task_postrun.connect
591
+ def on_task_postrun(task_id=None, **_):
592
+ """Clear the in-flight task once it has finished (clean completion)."""
593
+ from pyworkflow.celery.reschedule import track_task_end
594
+
595
+ with contextlib.suppress(Exception):
596
+ track_task_end(task_id)
597
+
598
+
558
599
  def get_celery_app() -> Celery:
559
600
  """
560
601
  Get the global Celery application instance.
@@ -0,0 +1,339 @@
1
+ """
2
+ Reschedule in-flight tasks when a worker is shut down (SIGTERM).
3
+
4
+ When a Celery worker pod is reclaimed (e.g. an AWS spot node drain), it receives
5
+ SIGTERM and warm-shuts-down. Any step task that was executing is *not* promptly
6
+ redelivered: with ``task_acks_late=True`` the broker only re-queues it after the
7
+ Redis ``visibility_timeout`` (an hour by default), and the parent workflow stays
8
+ SUSPENDED that whole time -- so the user-facing flow silently hangs with no output.
9
+
10
+ This module makes the worker re-enqueue its in-flight task(s) *immediately* on
11
+ shutdown so a live worker resumes them within seconds instead of ~1h.
12
+
13
+ Why a shared Redis registry (not process-local state)
14
+ -----------------------------------------------------
15
+ Tasks execute in forked **child** processes, but the shutdown hook runs in the
16
+ **main** process. The main process can't see a child's in-memory task args, so
17
+ children publish what they are running to a per-pod Redis hash
18
+ (``pyworkflow:inflight:<pod>``) on ``task_prerun`` and remove it on
19
+ ``task_postrun``. On shutdown the main process reads that hash and re-enqueues
20
+ every entry.
21
+
22
+ Why a consumer bootstep (event-based, no sleep)
23
+ -----------------------------------------------
24
+ The re-enqueue must happen *after* this worker's broker consumer is cancelled --
25
+ otherwise the worker's own still-active consumer grabs the message straight back
26
+ into ``unacked`` (stranded until the visibility_timeout). There is no Celery
27
+ *signal* for "consumer cancelled" (``worker_shutting_down`` fires before it;
28
+ ``worker_shutdown`` fires after the pool has drained the long task -- far too
29
+ late). So we hook the consumer blueprint directly with a bootstep that
30
+ ``requires`` the ``Tasks`` step: its ``stop()`` runs as the consumer is torn
31
+ down (early in shutdown, before the pool drain). There we cancel the task
32
+ consumer ourselves -- guaranteeing this worker stops fetching -- and only then
33
+ re-enqueue, so the message lands in the *ready* queue and another worker's
34
+ blocking BRPOP picks it up immediately. Event-based, deterministic, no sleep.
35
+
36
+ Safety against double execution
37
+ -------------------------------
38
+ ``execute_step_task`` short-circuits on an existing ``STEP_COMPLETED`` event (and
39
+ on a terminal run status), and ``_record_step_completion_and_resume`` re-checks
40
+ before recording, so at most one completion is ever recorded no matter how many
41
+ copies run. Only ``SingletonWorkflowTask`` tasks that declare ``unique_on`` are
42
+ rescheduled -- those are idempotent by run_id/step_id. Tasks without a uniqueness
43
+ key are left alone.
44
+
45
+ Gate: ``PYWORKFLOW_RESCHEDULE_ON_SIGTERM`` (default on; set to ``0``/``false``/``no``
46
+ to disable and fall back to the old visibility_timeout redelivery behaviour).
47
+ """
48
+
49
+ import json
50
+ import os
51
+ import socket
52
+ import threading
53
+ from typing import Any
54
+
55
+ from celery import bootsteps
56
+ from loguru import logger
57
+
58
+ # Registry hash self-expires so a hard-killed pod (no postrun) cannot leak entries.
59
+ _REGISTRY_TTL_SECONDS = 6 * 3600
60
+ _REGISTRY_KEY_PREFIX = "pyworkflow:inflight:"
61
+
62
+ _backend_lock = threading.Lock()
63
+ _backend: Any = None
64
+ _backend_resolved = False
65
+
66
+
67
+ def _enabled() -> bool:
68
+ return os.getenv("PYWORKFLOW_RESCHEDULE_ON_SIGTERM", "1").strip().lower() not in (
69
+ "0",
70
+ "false",
71
+ "no",
72
+ )
73
+
74
+
75
+ def _pod_id() -> str:
76
+ """Stable per-pod id shared by the main process and its forked children."""
77
+ return os.environ.get("HOSTNAME") or socket.gethostname()
78
+
79
+
80
+ def _registry_key() -> str:
81
+ return f"{_REGISTRY_KEY_PREFIX}{_pod_id()}"
82
+
83
+
84
+ def _get_backend() -> Any:
85
+ """
86
+ Cached sentinel-aware Redis backend, reusing the singleton lock configuration
87
+ (same broker/sentinel the task locks already use). Returns None when no Redis
88
+ backend is configured, in which case rescheduling is a no-op.
89
+ """
90
+ global _backend, _backend_resolved
91
+ if _backend_resolved:
92
+ return _backend
93
+ with _backend_lock:
94
+ if _backend_resolved:
95
+ return _backend
96
+ try:
97
+ from pyworkflow.celery.app import celery_app
98
+ from pyworkflow.celery.singleton import RedisLockBackend, SingletonConfig
99
+
100
+ cfg = SingletonConfig(celery_app)
101
+ url = cfg.backend_url
102
+ if url:
103
+ _backend = RedisLockBackend(
104
+ url,
105
+ is_sentinel=cfg.is_sentinel,
106
+ sentinel_master=cfg.sentinel_master,
107
+ )
108
+ except Exception as exc:
109
+ logger.warning(f"reschedule: could not initialise redis backend: {exc}")
110
+ _backend = None
111
+ _backend_resolved = True
112
+ return _backend
113
+
114
+
115
+ def _resolve_task(name: str) -> Any:
116
+ """Look up a registered task instance by name (used to re-enqueue)."""
117
+ from pyworkflow.celery.app import celery_app
118
+
119
+ return celery_app.tasks.get(name)
120
+
121
+
122
+ def _resolve_queue(task: Any) -> str | None:
123
+ """Best-effort queue the task was delivered on, so the re-enqueue lands there."""
124
+ try:
125
+ delivery_info = getattr(task.request, "delivery_info", None) or {}
126
+ routing_key = delivery_info.get("routing_key")
127
+ if routing_key:
128
+ return routing_key
129
+ except Exception:
130
+ pass
131
+ return getattr(task, "queue", None)
132
+
133
+
134
+ def track_task_start(
135
+ task: Any,
136
+ task_id: str | None,
137
+ args: Any,
138
+ kwargs: Any,
139
+ ) -> None:
140
+ """Publish a task as in-flight (per pod) so it can be re-enqueued on SIGTERM."""
141
+ if not _enabled() or task is None or task_id is None:
142
+ return
143
+
144
+ # Import here to avoid import cycles at module load.
145
+ from pyworkflow.celery.singleton import SingletonWorkflowTask
146
+
147
+ # Only re-enqueue tasks keyed by a uniqueness field (run_id/step_id). These are
148
+ # idempotent; blindly re-enqueuing a non-unique task could start duplicate runs.
149
+ if not isinstance(task, SingletonWorkflowTask) or not task.unique_on:
150
+ return
151
+
152
+ backend = _get_backend()
153
+ if backend is None:
154
+ return
155
+
156
+ descriptor = json.dumps(
157
+ {
158
+ "name": task.name,
159
+ "args": list(args or ()),
160
+ "kwargs": dict(kwargs or {}),
161
+ "queue": _resolve_queue(task),
162
+ },
163
+ default=str,
164
+ )
165
+ key = _registry_key()
166
+ try:
167
+ backend._execute_with_refresh(lambda: backend.redis.hset(key, task_id, descriptor))
168
+ backend._execute_with_refresh(lambda: backend.redis.expire(key, _REGISTRY_TTL_SECONDS))
169
+ except Exception as exc:
170
+ logger.warning(f"reschedule: failed to record in-flight task {task_id}: {exc}")
171
+
172
+
173
+ def track_task_end(task_id: str | None) -> None:
174
+ """Remove a task from the in-flight registry once it has finished."""
175
+ if not _enabled() or task_id is None:
176
+ return
177
+ backend = _get_backend()
178
+ if backend is None:
179
+ return
180
+ try:
181
+ backend._execute_with_refresh(lambda: backend.redis.hdel(_registry_key(), task_id))
182
+ except Exception as exc:
183
+ logger.warning(f"reschedule: failed to clear in-flight task {task_id}: {exc}")
184
+
185
+
186
+ def reschedule_inflight_on_shutdown() -> None:
187
+ """
188
+ Re-enqueue every task still recorded as in-flight for this pod. Assumes the
189
+ caller has already cancelled this worker's task consumer (see
190
+ :class:`RescheduleConsumerStep`) so the fresh copy is not re-grabbed. Never
191
+ raises -- shutdown must not be blocked.
192
+ """
193
+ if not _enabled():
194
+ return
195
+ backend = _get_backend()
196
+ if backend is None:
197
+ return
198
+
199
+ key = _registry_key()
200
+ try:
201
+ entries = backend._execute_with_refresh(lambda: backend.redis.hgetall(key)) or {}
202
+ except Exception as exc:
203
+ logger.warning(f"reschedule: failed to read in-flight registry: {exc}")
204
+ return
205
+
206
+ if not entries:
207
+ return
208
+
209
+ for task_id, raw in entries.items():
210
+ try:
211
+ descriptor = json.loads(raw)
212
+ task = _resolve_task(descriptor["name"])
213
+ if task is None:
214
+ logger.warning(
215
+ f"reschedule: unknown task {descriptor.get('name')!r}, "
216
+ f"cannot re-enqueue {task_id}"
217
+ )
218
+ continue
219
+
220
+ args = descriptor.get("args") or []
221
+ kwargs = descriptor.get("kwargs") or {}
222
+ queue = descriptor.get("queue")
223
+
224
+ # Release the singleton lock held by the dying task so the re-enqueued
225
+ # copy is not rejected as a duplicate. release_lock() regenerates the
226
+ # exact lock key from the same args/kwargs the running task holds.
227
+ try:
228
+ task.release_lock(task_args=list(args), task_kwargs=kwargs)
229
+ except Exception as exc:
230
+ logger.warning(
231
+ f"reschedule: failed to release lock for {task.name} "
232
+ f"(old_task_id={task_id}): {exc}"
233
+ )
234
+
235
+ options = {"queue": queue} if queue else {}
236
+ task.apply_async(args=list(args), kwargs=kwargs, **options)
237
+ logger.warning(
238
+ f"reschedule: re-enqueued in-flight task on shutdown "
239
+ f"task={task.name} old_task_id={task_id} queue={queue}"
240
+ )
241
+ except Exception as exc:
242
+ logger.opt(exception=True).error(
243
+ f"reschedule: failed to re-enqueue task {task_id}: {exc}"
244
+ )
245
+
246
+ # Drop the registry for this pod; a fresh worker starts with a clean slate.
247
+ try:
248
+ backend._execute_with_refresh(lambda: backend.redis.delete(key))
249
+ except Exception as exc:
250
+ logger.warning(f"reschedule: failed to clear in-flight registry: {exc}")
251
+
252
+
253
+ def _is_worker_stopping() -> bool:
254
+ """
255
+ True only when the worker is actually shutting down (SIGTERM warm or cold),
256
+ not on a transient consumer restart (e.g. broker reconnect), which also tears
257
+ the consumer down. Celery's SIGTERM handler sets these flags before the
258
+ consumer is stopped.
259
+ """
260
+ try:
261
+ from celery.worker import state
262
+
263
+ # Identity checks (matching celery.worker.state): the flags are set to the
264
+ # exit code, which can be 0 (EX_OK) -- `0 == False`, so membership/equality
265
+ # against False would wrongly treat a clean exitcode-0 shutdown as "not
266
+ # stopping" and skip the reschedule.
267
+ def _set(v: Any) -> bool:
268
+ return v is not None and v is not False
269
+
270
+ return _set(state.should_stop) or _set(state.should_terminate)
271
+ except Exception:
272
+ return False
273
+
274
+
275
+ def on_consumer_stop(consumer: Any) -> None:
276
+ """
277
+ Invoked from the consumer bootstep as the consumer is torn down. Cancels this
278
+ worker's task consumer (so it stops fetching) and re-enqueues in-flight work.
279
+ """
280
+ if not _enabled() or not _is_worker_stopping():
281
+ return
282
+
283
+ # Cancel our own task consumer first: once we stop BRPOPing, the re-enqueued
284
+ # message stays in the ready list for another worker instead of being grabbed
285
+ # back here into `unacked`. Tasks.stop() will cancel again later (idempotent).
286
+ task_consumer = getattr(consumer, "task_consumer", None)
287
+ if task_consumer is not None:
288
+ try:
289
+ task_consumer.cancel()
290
+ except Exception as exc:
291
+ logger.warning(f"reschedule: failed to cancel task consumer: {exc}")
292
+
293
+ # Cancelling is not enough on the Redis transport: a BRPOP this worker already
294
+ # issued is accepted by the server and will still pop the *next* message pushed
295
+ # to those queues -- including the copy we are about to re-enqueue. The dying
296
+ # worker then exits before recording it in `unacked`, so the message is lost
297
+ # entirely (neither in the ready list nor restorable via visibility_timeout).
298
+ # Drop the consumer's broker connection so any in-flight BRPOP is aborted
299
+ # server-side *before* we re-publish; the re-enqueue itself uses the producer
300
+ # pool (a separate connection), so it is unaffected.
301
+ _abort_consumer_fetching(consumer)
302
+
303
+ reschedule_inflight_on_shutdown()
304
+
305
+
306
+ def _abort_consumer_fetching(consumer: Any) -> None:
307
+ """Close the consumer's broker connection to abort any in-flight BRPOP."""
308
+ conn = getattr(consumer, "connection", None)
309
+ if conn is None:
310
+ return
311
+ try:
312
+ # collect() tears down channels + the underlying socket, aborting the
313
+ # pending BRPOP. Never raises into shutdown.
314
+ conn.collect()
315
+ except Exception as exc:
316
+ logger.warning(f"reschedule: failed to drop consumer connection: {exc}")
317
+
318
+
319
+ class RescheduleConsumerStep(bootsteps.StartStopStep):
320
+ """
321
+ Consumer bootstep that re-enqueues this worker's in-flight tasks the moment
322
+ the consumer is torn down on shutdown -- event-based, no polling/sleep.
323
+
324
+ ``requires`` the task consumer (``Tasks``) so that (a) ``c.task_consumer``
325
+ exists when our ``stop()`` runs and (b) we run *before* ``Tasks.stop()``,
326
+ letting us cancel the consumer ourselves and re-enqueue while it's guaranteed
327
+ not fetching.
328
+ """
329
+
330
+ requires = ("celery.worker.consumer:Tasks",)
331
+
332
+ def start(self, c: Any) -> None: # noqa: D401 - nothing to start
333
+ pass
334
+
335
+ def stop(self, c: Any) -> None:
336
+ try:
337
+ on_consumer_stop(c)
338
+ except Exception as exc: # never block consumer teardown
339
+ logger.warning(f"reschedule: consumer-stop hook failed: {exc}")
@@ -81,6 +81,7 @@ pyworkflow/aws/testing.py
81
81
  pyworkflow/celery/__init__.py
82
82
  pyworkflow/celery/app.py
83
83
  pyworkflow/celery/loop.py
84
+ pyworkflow/celery/reschedule.py
84
85
  pyworkflow/celery/scheduler.py
85
86
  pyworkflow/celery/singleton.py
86
87
  pyworkflow/celery/tasks.py
@@ -182,6 +183,7 @@ pyworkflow/utils/duration.py
182
183
  pyworkflow/utils/helpers.py
183
184
  pyworkflow/utils/schedule.py
184
185
  tests/integration/__init__.py
186
+ tests/integration/_reschedule_e2e_app.py
185
187
  tests/integration/test_cancellation.py
186
188
  tests/integration/test_cassandra_storage.py
187
189
  tests/integration/test_child_workflows.py
@@ -189,6 +191,8 @@ tests/integration/test_continue_as_new.py
189
191
  tests/integration/test_dynamodb_storage.py
190
192
  tests/integration/test_fault_tolerance.py
191
193
  tests/integration/test_list_runs_pagination.py
194
+ tests/integration/test_reschedule.py
195
+ tests/integration/test_reschedule_e2e.py
192
196
  tests/integration/test_schedule_storage.py
193
197
  tests/integration/test_schema_migrations.py
194
198
  tests/integration/test_singleton.py
@@ -210,6 +214,7 @@ tests/unit/test_parent_run_id.py
210
214
  tests/unit/test_primitives_from_steps.py
211
215
  tests/unit/test_registry.py
212
216
  tests/unit/test_replay.py
217
+ tests/unit/test_reschedule.py
213
218
  tests/unit/test_retention.py
214
219
  tests/unit/test_schedule_schemas.py
215
220
  tests/unit/test_schedule_utils.py
@@ -0,0 +1,41 @@
1
+ """
2
+ Standalone workflow module for the SIGTERM-reschedule end-to-end test.
3
+
4
+ Imported by the worker subprocess (via PYWORKFLOW_MODULE) and by the CLI that
5
+ starts the workflow. The single step blocks until a `release` marker file
6
+ appears, so the test can deterministically catch it mid-execution, SIGTERM the
7
+ worker, and prove a fresh worker re-runs it and the workflow completes.
8
+
9
+ Marker files live under $E2E_MARKER_DIR:
10
+ - writes `started` as soon as the step runs (so the test knows it's in-flight)
11
+ - waits for `release` before returning
12
+ """
13
+
14
+ import asyncio
15
+ import os
16
+ from pathlib import Path
17
+
18
+ from pyworkflow import step, workflow
19
+
20
+
21
+ def _marker_dir() -> Path:
22
+ return Path(os.environ["E2E_MARKER_DIR"])
23
+
24
+
25
+ @step(max_retries=1)
26
+ async def blocking_step() -> str:
27
+ d = _marker_dir()
28
+ d.mkdir(parents=True, exist_ok=True)
29
+ # Signal that the step is executing (the test waits for this before SIGTERM).
30
+ (d / "started").write_text("1")
31
+ # Block until released, so the step is reliably in-flight when the worker dies.
32
+ for _ in range(240): # up to ~120s
33
+ if (d / "release").exists():
34
+ return "released"
35
+ await asyncio.sleep(0.5)
36
+ raise TimeoutError("release marker never appeared")
37
+
38
+
39
+ @workflow(durable=True)
40
+ async def e2e_blocking_workflow() -> str:
41
+ return await blocking_step()
@@ -0,0 +1,133 @@
1
+ """
2
+ Integration tests for SIGTERM task rescheduling against a REAL Redis.
3
+
4
+ These exercise the actual pieces the unit tests fake out:
5
+ - the per-pod in-flight registry hash in real Redis,
6
+ - real singleton-lock release + re-acquire,
7
+ - a real broker publish (apply_async lands a message on the queue's Redis list).
8
+
9
+ Requires a Redis at localhost:6379. Skipped otherwise. Uses a dedicated db (14)
10
+ which is flushed around each test, so it never touches app data on db 0.
11
+ """
12
+
13
+ import pytest
14
+
15
+ from pyworkflow.celery import reschedule
16
+
17
+ try:
18
+ import redis as _redis
19
+
20
+ _client = _redis.from_url("redis://localhost:6379/0")
21
+ _client.ping()
22
+ REDIS_AVAILABLE = True
23
+ except Exception:
24
+ REDIS_AVAILABLE = False
25
+
26
+ pytestmark = [
27
+ pytest.mark.integration,
28
+ pytest.mark.skipif(not REDIS_AVAILABLE, reason="Redis not available"),
29
+ ]
30
+
31
+ TEST_DB_URL = "redis://localhost:6379/14"
32
+ STEPS_QUEUE = "pyworkflow.steps"
33
+
34
+
35
+ @pytest.fixture
36
+ def env(monkeypatch):
37
+ """Real Redis-backed reschedule wiring on an isolated db, fixed pod id."""
38
+ from pyworkflow.celery.app import create_celery_app
39
+ from pyworkflow.celery.singleton import RedisLockBackend, SingletonWorkflowTask
40
+
41
+ raw = _redis.from_url(TEST_DB_URL, decode_responses=True)
42
+ raw.flushdb()
43
+
44
+ monkeypatch.delenv("PYWORKFLOW_RESCHEDULE_ON_SIGTERM", raising=False)
45
+ monkeypatch.setenv("HOSTNAME", "itest-pod")
46
+
47
+ app = create_celery_app(broker_url=TEST_DB_URL, result_backend=TEST_DB_URL)
48
+
49
+ @app.task(
50
+ base=SingletonWorkflowTask,
51
+ name="test.reschedule_probe",
52
+ queue=STEPS_QUEUE,
53
+ unique_on=["run_id"],
54
+ )
55
+ def probe(run_id, payload=None): # pragma: no cover - never executed (no worker)
56
+ return None
57
+
58
+ backend = RedisLockBackend(TEST_DB_URL)
59
+ monkeypatch.setattr(reschedule, "_get_backend", lambda: backend)
60
+ monkeypatch.setattr(reschedule, "_resolve_task", lambda name: app.tasks.get(name))
61
+
62
+ registry_key = "pyworkflow:inflight:itest-pod"
63
+
64
+ yield {
65
+ "raw": raw,
66
+ "app": app,
67
+ "probe": probe,
68
+ "backend": backend,
69
+ "registry_key": registry_key,
70
+ }
71
+
72
+ raw.flushdb()
73
+
74
+
75
+ def test_track_writes_and_clears_real_hash(env):
76
+ probe, raw, key = env["probe"], env["raw"], env["registry_key"]
77
+
78
+ reschedule.track_task_start(probe, "tid-1", (), {"run_id": "rX"})
79
+ entries = raw.hgetall(key)
80
+ assert "tid-1" in entries
81
+ assert "test.reschedule_probe" in entries["tid-1"] # descriptor carries task name
82
+
83
+ reschedule.track_task_end("tid-1")
84
+ assert raw.hgetall(key) == {}
85
+
86
+
87
+ def test_reschedule_releases_lock_and_reenqueues(env):
88
+ probe, raw, key = env["probe"], env["raw"], env["registry_key"]
89
+
90
+ # Simulate the running task holding its singleton lock.
91
+ lock_key = probe.generate_lock("test.reschedule_probe", [], {"run_id": "rX"})
92
+ assert probe.acquire_lock(lock_key, "tid-1") is True
93
+ assert raw.get(lock_key) == "tid-1"
94
+
95
+ reschedule.track_task_start(probe, "tid-1", (), {"run_id": "rX"})
96
+ llen_before = raw.llen(STEPS_QUEUE)
97
+
98
+ reschedule.reschedule_inflight_on_shutdown()
99
+
100
+ # A fresh copy was published to the real broker queue...
101
+ assert raw.llen(STEPS_QUEUE) == llen_before + 1
102
+ # ...and it re-acquired the singleton lock under a NEW task id (so the dying
103
+ # task's lock no longer blocks it).
104
+ holder = raw.get(lock_key)
105
+ assert holder is not None and holder != "tid-1"
106
+ # Registry drained.
107
+ assert raw.hgetall(key) == {}
108
+
109
+
110
+ def test_direct_apply_async_is_deduped_without_release(env):
111
+ """Documents WHY reschedule must release the lock first: a plain re-enqueue
112
+ while the lock is held is silently deduped and never reaches the broker."""
113
+ probe, raw = env["probe"], env["raw"]
114
+
115
+ lock_key = probe.generate_lock("test.reschedule_probe", [], {"run_id": "rX"})
116
+ assert probe.acquire_lock(lock_key, "tid-1") is True
117
+ llen_before = raw.llen(STEPS_QUEUE)
118
+
119
+ probe.apply_async(kwargs={"run_id": "rX"}) # no release -> singleton dedup
120
+
121
+ assert raw.llen(STEPS_QUEUE) == llen_before # nothing enqueued
122
+ assert raw.get(lock_key) == "tid-1" # lock untouched
123
+
124
+
125
+ def test_disabled_writes_nothing(env, monkeypatch):
126
+ monkeypatch.setenv("PYWORKFLOW_RESCHEDULE_ON_SIGTERM", "0")
127
+ probe, raw, key = env["probe"], env["raw"], env["registry_key"]
128
+
129
+ reschedule.track_task_start(probe, "tid-1", (), {"run_id": "rX"})
130
+ reschedule.reschedule_inflight_on_shutdown()
131
+
132
+ assert raw.hgetall(key) == {}
133
+ assert raw.llen(STEPS_QUEUE) == 0