edda-framework 0.11.0__tar.gz → 0.12.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. {edda_framework-0.11.0 → edda_framework-0.12.0}/.github/workflows/ci.yml +3 -1
  2. {edda_framework-0.11.0 → edda_framework-0.12.0}/.github/workflows/docs.yml +3 -1
  3. {edda_framework-0.11.0 → edda_framework-0.12.0}/.github/workflows/release.yml +6 -2
  4. edda_framework-0.12.0/.gitmodules +3 -0
  5. {edda_framework-0.11.0 → edda_framework-0.12.0}/PKG-INFO +43 -3
  6. {edda_framework-0.11.0 → edda_framework-0.12.0}/README.md +42 -2
  7. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/core-features/durable-execution/replay.md +3 -2
  8. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/core-features/messages.md +35 -1
  9. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/getting-started/installation.md +82 -0
  10. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/integrations/pydantic-rpc.md +1 -1
  11. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/app.py +203 -35
  12. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/channels.py +57 -12
  13. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/context.py +24 -0
  14. edda_framework-0.12.0/edda/migrations/mysql/20251217000000_initial_schema.sql +284 -0
  15. edda_framework-0.12.0/edda/migrations/postgresql/20251217000000_initial_schema.sql +284 -0
  16. edda_framework-0.12.0/edda/migrations/sqlite/20251217000000_initial_schema.sql +284 -0
  17. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/outbox/relayer.py +34 -7
  18. edda_framework-0.12.0/edda/storage/migrations.py +435 -0
  19. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/storage/models.py +2 -0
  20. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/storage/pg_notify.py +5 -8
  21. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/storage/sqlalchemy_storage.py +97 -61
  22. {edda_framework-0.11.0 → edda_framework-0.12.0}/pyproject.toml +8 -2
  23. edda_framework-0.12.0/schema/.dbmate.yml +16 -0
  24. edda_framework-0.12.0/schema/.git +1 -0
  25. edda_framework-0.12.0/schema/.gitignore +20 -0
  26. edda_framework-0.12.0/schema/LICENSE +21 -0
  27. edda_framework-0.12.0/schema/README.md +53 -0
  28. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/conftest.py +32 -10
  29. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_app.py +46 -39
  30. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_auto_migration.py +6 -1
  31. edda_framework-0.12.0/tests/test_channel_direct.py +329 -0
  32. edda_framework-0.12.0/tests/test_migrations_integration.py +211 -0
  33. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_pg_notify.py +5 -5
  34. edda_framework-0.12.0/tests/test_polling_optimization.py +1026 -0
  35. {edda_framework-0.11.0 → edda_framework-0.12.0}/uv.lock +17 -17
  36. {edda_framework-0.11.0 → edda_framework-0.12.0}/.gitignore +0 -0
  37. {edda_framework-0.11.0 → edda_framework-0.12.0}/.python-version +0 -0
  38. {edda_framework-0.11.0 → edda_framework-0.12.0}/Justfile +0 -0
  39. {edda_framework-0.11.0 → edda_framework-0.12.0}/LICENSE +0 -0
  40. {edda_framework-0.11.0 → edda_framework-0.12.0}/demo_app.py +0 -0
  41. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/api/reference.md +0 -0
  42. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/core-features/events/cloudevents-http-binding.md +0 -0
  43. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/core-features/events/postgres-notify.md +0 -0
  44. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/core-features/events/wait-event.md +0 -0
  45. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/core-features/hooks.md +0 -0
  46. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/core-features/retry.md +0 -0
  47. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/core-features/saga-compensation.md +0 -0
  48. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/core-features/transactional-outbox.md +0 -0
  49. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/core-features/workflows-activities.md +0 -0
  50. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/examples/ecommerce.md +0 -0
  51. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/examples/events.md +0 -0
  52. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/examples/fastapi-integration.md +0 -0
  53. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/examples/saga.md +0 -0
  54. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/examples/simple.md +0 -0
  55. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/getting-started/concepts.md +0 -0
  56. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/getting-started/first-workflow.md +0 -0
  57. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/getting-started/quick-start.md +0 -0
  58. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/index.md +0 -0
  59. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/integrations/mcp.md +0 -0
  60. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/integrations/mirascope.md +0 -0
  61. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/integrations/opentelemetry.md +0 -0
  62. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/viewer-ui/images/cloudevents-cli-trigger.png +0 -0
  63. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/viewer-ui/images/compensation-execution.png +0 -0
  64. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/viewer-ui/images/detail-page-loan-approval.png +0 -0
  65. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/viewer-ui/images/detail-page-match-case.png +0 -0
  66. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/viewer-ui/images/nested-pydantic-form.png +0 -0
  67. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/viewer-ui/images/start-workflow-form-pydantic.png +0 -0
  68. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/viewer-ui/images/wait-event-visualization.png +0 -0
  69. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/viewer-ui/images/workflow-list-view.png +0 -0
  70. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/viewer-ui/images/workflow-selection-dropdown.png +0 -0
  71. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/viewer-ui/setup.md +0 -0
  72. {edda_framework-0.11.0 → edda_framework-0.12.0}/docs/viewer-ui/visualization.md +0 -0
  73. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/__init__.py +0 -0
  74. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/activity.py +0 -0
  75. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/compensation.py +0 -0
  76. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/exceptions.py +0 -0
  77. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/hooks.py +0 -0
  78. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/integrations/__init__.py +0 -0
  79. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/integrations/mcp/__init__.py +0 -0
  80. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/integrations/mcp/decorators.py +0 -0
  81. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/integrations/mcp/server.py +0 -0
  82. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/integrations/mirascope/__init__.py +0 -0
  83. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/integrations/mirascope/agent.py +0 -0
  84. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/integrations/mirascope/call.py +0 -0
  85. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/integrations/mirascope/decorator.py +0 -0
  86. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/integrations/mirascope/types.py +0 -0
  87. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/integrations/opentelemetry/__init__.py +0 -0
  88. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/integrations/opentelemetry/hooks.py +0 -0
  89. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/locking.py +0 -0
  90. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/outbox/__init__.py +0 -0
  91. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/outbox/transactional.py +0 -0
  92. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/pydantic_utils.py +0 -0
  93. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/replay.py +0 -0
  94. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/retry.py +0 -0
  95. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/serialization/__init__.py +0 -0
  96. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/serialization/base.py +0 -0
  97. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/serialization/json.py +0 -0
  98. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/storage/__init__.py +0 -0
  99. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/storage/notify_base.py +0 -0
  100. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/storage/protocol.py +0 -0
  101. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/viewer_ui/__init__.py +0 -0
  102. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/viewer_ui/app.py +0 -0
  103. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/viewer_ui/components.py +0 -0
  104. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/viewer_ui/data_service.py +0 -0
  105. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/viewer_ui/theme.py +0 -0
  106. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/visualizer/__init__.py +0 -0
  107. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/visualizer/ast_analyzer.py +0 -0
  108. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/visualizer/mermaid_generator.py +0 -0
  109. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/workflow.py +0 -0
  110. {edda_framework-0.11.0 → edda_framework-0.12.0}/edda/wsgi.py +0 -0
  111. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/__init__.py +0 -0
  112. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/cancellable_workflow.py +0 -0
  113. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/compensation_workflow.py +0 -0
  114. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/event_waiting_app.py +0 -0
  115. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/event_waiting_workflow.py +0 -0
  116. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/event_waiting_workflow_complete.py +0 -0
  117. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/long_running_loop.py +0 -0
  118. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/mcp/README.md +0 -0
  119. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/mcp/order_processing_mcp.py +0 -0
  120. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/mcp/prompts_example.py +0 -0
  121. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/mcp/remote_server_example.py +0 -0
  122. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/mcp/simple_mcp_server.py +0 -0
  123. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/message_passing.py +0 -0
  124. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/mirascope/__init__.py +0 -0
  125. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/mirascope/durable_agent.py +0 -0
  126. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/mirascope/multi_turn.py +0 -0
  127. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/mirascope/simple_call.py +0 -0
  128. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/mirascope/with_tools.py +0 -0
  129. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/observability_with_logfire.py +0 -0
  130. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/observability_with_opentelemetry.py +0 -0
  131. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/pydantic_rpc_integration.py +0 -0
  132. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/pydantic_saga.py +0 -0
  133. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/retry_example.py +0 -0
  134. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/retry_with_compensation.py +0 -0
  135. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/simple_workflow.py +0 -0
  136. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/typeddict_example.py +0 -0
  137. {edda_framework-0.11.0 → edda_framework-0.12.0}/examples/with_outbox.py +0 -0
  138. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/__init__.py +0 -0
  139. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/__init__.py +0 -0
  140. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/mcp/__init__.py +0 -0
  141. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/mcp/test_cancel.py +0 -0
  142. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/mcp/test_integration.py +0 -0
  143. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/mcp/test_jsonrpc.py +0 -0
  144. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/mcp/test_prompts.py +0 -0
  145. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/mcp/test_server.py +0 -0
  146. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/mirascope/__init__.py +0 -0
  147. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/mirascope/test_agent.py +0 -0
  148. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/mirascope/test_call.py +0 -0
  149. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/mirascope/test_decorator.py +0 -0
  150. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/mirascope/test_types.py +0 -0
  151. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/opentelemetry/__init__.py +0 -0
  152. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/integrations/opentelemetry/test_hooks.py +0 -0
  153. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_activity.py +0 -0
  154. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_activity_retry.py +0 -0
  155. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_activity_sync.py +0 -0
  156. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_ast_analyzer.py +0 -0
  157. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_atomic_wait_event.py +0 -0
  158. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_binary_data.py +0 -0
  159. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_channel_competing.py +0 -0
  160. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_channel_transactional.py +0 -0
  161. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_cloudevents_http_binding.py +0 -0
  162. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_compensation.py +0 -0
  163. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_compensation_crash_recovery.py.wip +0 -0
  164. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_concurrent_outbox.py +0 -0
  165. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_context.py +0 -0
  166. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_ctx_session.py +0 -0
  167. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_distributed_event_delivery.py +0 -0
  168. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_events.py +0 -0
  169. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_instance_id_routing.py +0 -0
  170. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_lock_race_condition.py +0 -0
  171. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_lock_timeout_customization.py +0 -0
  172. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_locking.py +0 -0
  173. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_message_cleanup.py +0 -0
  174. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_message_delivery_lock.py +0 -0
  175. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_messages.py +0 -0
  176. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_multidb_storage.py +0 -0
  177. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_outbox.py +0 -0
  178. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_pydantic_activity.py +0 -0
  179. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_pydantic_enum.py +0 -0
  180. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_pydantic_events.py +0 -0
  181. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_pydantic_saga.py +0 -0
  182. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_pydantic_utils.py +0 -0
  183. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_receive_timeout.py +0 -0
  184. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_received_event.py +0 -0
  185. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_recur.py +0 -0
  186. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_recur_cleanup.py +0 -0
  187. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_replay.py +0 -0
  188. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_retry_policy.py +0 -0
  189. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_saga_parameter_extraction.py +0 -0
  190. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_serialization.py +0 -0
  191. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_skip_locked.py +0 -0
  192. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_stale_workflow_recovery.py +0 -0
  193. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_storage.py +0 -0
  194. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_storage_mysql.py +0 -0
  195. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_storage_postgresql.py +0 -0
  196. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_transactions.py +0 -0
  197. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_viewer_pagination.py +0 -0
  198. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_viewer_pydantic_form.py +0 -0
  199. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_viewer_start_saga.py +0 -0
  200. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_wait_timer.py +0 -0
  201. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_workflow.py +0 -0
  202. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_workflow_auto_register.py +0 -0
  203. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_workflow_cancellation.py +0 -0
  204. {edda_framework-0.11.0 → edda_framework-0.12.0}/tests/test_workflow_resumption.py +0 -0
  205. {edda_framework-0.11.0 → edda_framework-0.12.0}/viewer_app.py +0 -0
  206. {edda_framework-0.11.0 → edda_framework-0.12.0}/zensical.toml +0 -0
@@ -45,7 +45,9 @@ jobs:
45
45
 
46
46
  steps:
47
47
  - name: Checkout code
48
- uses: actions/checkout@v4
48
+ uses: actions/checkout@v6
49
+ with:
50
+ submodules: true
49
51
 
50
52
  - name: Set up uv
51
53
  uses: astral-sh/setup-uv@v3
@@ -16,7 +16,9 @@ jobs:
16
16
  runs-on: ubuntu-latest
17
17
  steps:
18
18
  - name: Checkout code
19
- uses: actions/checkout@v4
19
+ uses: actions/checkout@v6
20
+ with:
21
+ submodules: true
20
22
 
21
23
  - name: Set up Python
22
24
  uses: actions/setup-python@v5
@@ -17,7 +17,9 @@ jobs:
17
17
 
18
18
  steps:
19
19
  - name: Checkout code
20
- uses: actions/checkout@v4
20
+ uses: actions/checkout@v6
21
+ with:
22
+ submodules: true
21
23
 
22
24
  - name: Set up uv
23
25
  uses: astral-sh/setup-uv@v3
@@ -29,7 +31,9 @@ jobs:
29
31
  run: uv sync
30
32
 
31
33
  - name: Build package
32
- run: uv build
34
+ run: |
35
+ uv build --sdist
36
+ uv build --wheel
33
37
 
34
38
  - name: Publish to PyPI
35
39
  uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,3 @@
1
+ [submodule "schema"]
2
+ path = schema
3
+ url = https://github.com/durax-io/schema.git
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: edda-framework
3
- Version: 0.11.0
3
+ Version: 0.12.0
4
4
  Summary: Lightweight Durable Execution Framework
5
5
  Project-URL: Homepage, https://github.com/i2y/edda
6
6
  Project-URL: Documentation, https://github.com/i2y/edda#readme
@@ -286,6 +286,46 @@ pip install "git+https://github.com/i2y/edda.git[postgresql,viewer]"
286
286
 
287
287
  > **Tip**: For PostgreSQL, install the `postgres-notify` extra for near-instant event delivery using LISTEN/NOTIFY instead of polling.
288
288
 
289
+ ### Database Schema Migration
290
+
291
+ **Automatic Migration (Default)**
292
+
293
+ Edda automatically applies database migrations at startup. No manual commands needed:
294
+
295
+ ```python
296
+ from edda import EddaApp
297
+
298
+ # Migrations are applied automatically
299
+ app = EddaApp(db_url="postgresql://user:pass@localhost/dbname")
300
+ ```
301
+
302
+ This is safe in multi-worker environments - Edda handles concurrent startup gracefully.
303
+
304
+ **Manual Migration with dbmate (Optional)**
305
+
306
+ For explicit schema control, you can disable auto-migration and use [dbmate](https://github.com/amacneil/dbmate):
307
+
308
+ ```python
309
+ # Disable auto-migration
310
+ app = EddaApp(
311
+ db_url="postgresql://...",
312
+ auto_migrate=False # Use dbmate-managed schema
313
+ )
314
+ ```
315
+
316
+ ```bash
317
+ # Install dbmate
318
+ brew install dbmate # macOS
319
+
320
+ # Add schema submodule
321
+ git submodule add https://github.com/durax-io/schema.git schema
322
+
323
+ # Run migration manually
324
+ DATABASE_URL="postgresql://user:pass@localhost/dbname" dbmate -d ./schema/db/migrations/postgresql up
325
+ ```
326
+
327
+ > **Note**: Edda's auto-migration uses the same SQL files as dbmate, maintaining full compatibility.
328
+
289
329
  ### Development Installation
290
330
 
291
331
  If you want to contribute to Edda or modify the framework itself:
@@ -293,7 +333,7 @@ If you want to contribute to Edda or modify the framework itself:
293
333
  ```bash
294
334
  # Clone repository
295
335
  git clone https://github.com/i2y/edda.git
296
- cd kairo
336
+ cd edda
297
337
  uv sync --all-extras
298
338
  ```
299
339
 
@@ -333,7 +373,7 @@ async def user_signup(ctx: WorkflowContext, email: str):
333
373
  return {"status": "completed"}
334
374
  ```
335
375
 
336
- **Activity IDs**: Activities are automatically identified with IDs like `"send_email:1"` for deterministic replay. Manual IDs are only needed for concurrent execution (e.g., `asyncio.gather`). See [MIGRATION_GUIDE_ACTIVITY_ID.md](MIGRATION_GUIDE_ACTIVITY_ID.md) for details.
376
+ **Activity IDs**: Activities are automatically identified with IDs like `"send_email:1"` for deterministic replay. Manual IDs are only needed for concurrent execution (e.g., `asyncio.gather`).
337
377
 
338
378
  ### Durable Execution
339
379
 
@@ -221,6 +221,46 @@ pip install "git+https://github.com/i2y/edda.git[postgresql,viewer]"
221
221
 
222
222
  > **Tip**: For PostgreSQL, install the `postgres-notify` extra for near-instant event delivery using LISTEN/NOTIFY instead of polling.
223
223
 
224
+ ### Database Schema Migration
225
+
226
+ **Automatic Migration (Default)**
227
+
228
+ Edda automatically applies database migrations at startup. No manual commands needed:
229
+
230
+ ```python
231
+ from edda import EddaApp
232
+
233
+ # Migrations are applied automatically
234
+ app = EddaApp(db_url="postgresql://user:pass@localhost/dbname")
235
+ ```
236
+
237
+ This is safe in multi-worker environments - Edda handles concurrent startup gracefully.
238
+
239
+ **Manual Migration with dbmate (Optional)**
240
+
241
+ For explicit schema control, you can disable auto-migration and use [dbmate](https://github.com/amacneil/dbmate):
242
+
243
+ ```python
244
+ # Disable auto-migration
245
+ app = EddaApp(
246
+ db_url="postgresql://...",
247
+ auto_migrate=False # Use dbmate-managed schema
248
+ )
249
+ ```
250
+
251
+ ```bash
252
+ # Install dbmate
253
+ brew install dbmate # macOS
254
+
255
+ # Add schema submodule
256
+ git submodule add https://github.com/durax-io/schema.git schema
257
+
258
+ # Run migration manually
259
+ DATABASE_URL="postgresql://user:pass@localhost/dbname" dbmate -d ./schema/db/migrations/postgresql up
260
+ ```
261
+
262
+ > **Note**: Edda's auto-migration uses the same SQL files as dbmate, maintaining full compatibility.
263
+
224
264
  ### Development Installation
225
265
 
226
266
  If you want to contribute to Edda or modify the framework itself:
@@ -228,7 +268,7 @@ If you want to contribute to Edda or modify the framework itself:
228
268
  ```bash
229
269
  # Clone repository
230
270
  git clone https://github.com/i2y/edda.git
231
- cd kairo
271
+ cd edda
232
272
  uv sync --all-extras
233
273
  ```
234
274
 
@@ -268,7 +308,7 @@ async def user_signup(ctx: WorkflowContext, email: str):
268
308
  return {"status": "completed"}
269
309
  ```
270
310
 
271
- **Activity IDs**: Activities are automatically identified with IDs like `"send_email:1"` for deterministic replay. Manual IDs are only needed for concurrent execution (e.g., `asyncio.gather`). See [MIGRATION_GUIDE_ACTIVITY_ID.md](MIGRATION_GUIDE_ACTIVITY_ID.md) for details.
311
+ **Activity IDs**: Activities are automatically identified with IDs like `"send_email:1"` for deterministic replay. Manual IDs are only needed for concurrent execution (e.g., `asyncio.gather`).
272
312
 
273
313
  ### Durable Execution
274
314
 
@@ -349,7 +349,7 @@ async def resume_workflow_endpoint(instance_id: str):
349
349
 
350
350
  Edda automatically recovers from crashes in two stages:
351
351
 
352
- #### 3-1. Stale Lock Cleanup (Implemented)
352
+ #### 3-1. Stale Lock Cleanup
353
353
 
354
354
  When a worker process crashes, its locks become "stale." Edda automatically cleans these up:
355
355
 
@@ -394,7 +394,7 @@ This background task starts automatically when `EddaApp` launches.
394
394
 
395
395
  The `status` field indicates whether the workflow was running normally (`"running"`) or executing compensations (`"compensating"`) when it crashed.
396
396
 
397
- #### 3-2. Automatic Workflow Resume (Implemented)
397
+ #### 3-2. Automatic Workflow Resume
398
398
 
399
399
  After cleaning stale locks, Edda automatically resumes workflows with `status="running"` or `status="compensating"`:
400
400
 
@@ -586,6 +586,7 @@ async with workflow_lock(storage, instance_id, worker_id, timeout_seconds=300):
586
586
  ```
587
587
 
588
588
  Features:
589
+
589
590
  - **5-minute timeout** by default (prevents indefinite locks)
590
591
  - **Worker ID tracking** (know which worker holds the lock)
591
592
  - **Stale lock cleanup** (automatic recovery after crashes)
@@ -47,6 +47,40 @@ async def notification_service(ctx: WorkflowContext, service_id: str):
47
47
 
48
48
  - `"broadcast"` (default): All subscribers receive all messages. Use for fan-out patterns like notifications.
49
49
  - `"competing"`: Each message is processed by only one subscriber. Use for job queues and task distribution.
50
+ - `"direct"`: Receive messages sent via `send_to()` to this specific instance. Syntactic sugar for point-to-point messaging.
51
+
52
+ **Using `mode="direct"`**:
53
+
54
+ The `"direct"` mode simplifies receiving messages sent via `send_to()`:
55
+
56
+ ```python
57
+ @workflow
58
+ async def direct_receiver(ctx: WorkflowContext, id: str):
59
+ # Subscribe to receive direct messages
60
+ await subscribe(ctx, "notifications", mode="direct")
61
+
62
+ # Wait for a message sent via send_to()
63
+ msg = await receive(ctx, "notifications")
64
+ return msg.data
65
+
66
+ @workflow
67
+ async def sender(ctx: WorkflowContext, receiver_id: str):
68
+ # Send directly to the receiver instance
69
+ await send_to(ctx, instance_id=receiver_id, data={"hello": "world"}, channel="notifications")
70
+ ```
71
+
72
+ This is equivalent to manually constructing the channel name:
73
+
74
+ ```python
75
+ # Without mode="direct" (manual approach)
76
+ direct_channel = f"notifications:{ctx.instance_id}"
77
+ await subscribe(ctx, direct_channel, mode="broadcast")
78
+ msg = await receive(ctx, direct_channel)
79
+
80
+ # With mode="direct" (simplified)
81
+ await subscribe(ctx, "notifications", mode="direct")
82
+ msg = await receive(ctx, "notifications")
83
+ ```
50
84
 
51
85
  #### `unsubscribe()`
52
86
 
@@ -202,7 +236,7 @@ async def subscribe(
202
236
 
203
237
  - `ctx`: Workflow context
204
238
  - `channel`: Channel name to subscribe to
205
- - `mode`: `"broadcast"` (all subscribers receive) or `"competing"` (one subscriber per message)
239
+ - `mode`: `"broadcast"` (all subscribers receive), `"competing"` (one subscriber per message), or `"direct"` (receive messages from `send_to()`)
206
240
 
207
241
  ### receive()
208
242
 
@@ -310,6 +310,88 @@ app = EddaApp(
310
310
  )
311
311
  ```
312
312
 
313
+ ### Schema Migration
314
+
315
+ #### Automatic Migration (Default)
316
+
317
+ Edda automatically applies database migrations at startup. No manual commands needed:
318
+
319
+ ```python
320
+ from edda import EddaApp
321
+
322
+ # Migrations are applied automatically at startup
323
+ app = EddaApp(
324
+ service_name="demo-service",
325
+ db_url="postgresql://user:pass@localhost/dbname"
326
+ )
327
+ ```
328
+
329
+ **Key features:**
330
+
331
+ - **Zero configuration**: Works out of the box
332
+ - **Multi-worker safe**: Handles concurrent startup gracefully (race condition protected)
333
+ - **dbmate compatible**: Uses the same SQL files and `schema_migrations` table
334
+ - **Incremental**: Only applies pending migrations
335
+
336
+ #### Manual Migration with dbmate (Optional)
337
+
338
+ For explicit schema control, you can disable auto-migration and use [dbmate](https://github.com/amacneil/dbmate):
339
+
340
+ ```python
341
+ # Disable auto-migration
342
+ app = EddaApp(
343
+ service_name="demo-service",
344
+ db_url="postgresql://...",
345
+ auto_migrate=False # Use dbmate-managed schema
346
+ )
347
+ ```
348
+
349
+ ```bash
350
+ # Install dbmate
351
+ brew install dbmate # macOS
352
+ # Linux: curl -fsSL https://github.com/amacneil/dbmate/releases/latest/download/dbmate-linux-amd64 -o /usr/local/bin/dbmate && chmod +x /usr/local/bin/dbmate
353
+
354
+ # Add schema submodule to your project
355
+ git submodule add https://github.com/durax-io/schema.git schema
356
+
357
+ # Run migration manually
358
+ DATABASE_URL="postgresql://user:pass@localhost/dbname" dbmate -d ./schema/db/migrations/postgresql up
359
+
360
+ # Check status
361
+ dbmate -d ./schema/db/migrations/postgresql status
362
+ ```
363
+
364
+ > **Note**: Edda's auto-migration uses the same SQL files as dbmate, so you can switch between modes freely.
365
+
366
+ ### Multi-Worker Configuration
367
+
368
+ When running multiple Edda workers (e.g., in Kubernetes or with multiple processes), Edda automatically coordinates background tasks using **leader election**. Only one worker runs maintenance tasks (timers, message cleanup, etc.) while others focus on workflow execution.
369
+
370
+ ```python
371
+ from edda import EddaApp
372
+
373
+ app = EddaApp(
374
+ service_name="demo-service",
375
+ db_url="postgresql://...",
376
+ # Leader election settings (optional - defaults work well for most cases)
377
+ leader_heartbeat_interval=15, # How often workers check/renew leadership
378
+ leader_lease_duration=45, # How long before a failed leader is replaced
379
+ )
380
+ ```
381
+
382
+ **Configuration options:**
383
+
384
+ | Parameter | Type | Default | Description |
385
+ |-----------|------|---------|-------------|
386
+ | `leader_heartbeat_interval` | `int` | `15` | Interval in seconds for leader heartbeat |
387
+ | `leader_lease_duration` | `int` | `45` | Duration in seconds before leadership expires |
388
+
389
+ **Notes:**
390
+
391
+ - Default values work well for most deployments
392
+ - Reduce `leader_lease_duration` for faster failover (minimum: 3x heartbeat interval)
393
+ - Leader election uses the database for coordination (no external dependencies)
394
+
313
395
  ## Next Steps
314
396
 
315
397
  - **[Quick Start](quick-start.md)**: Build your first workflow in 5 minutes
@@ -189,5 +189,5 @@ These models work seamlessly with Edda's Pydantic integration for:
189
189
  - [Workflows and Activities](../core-features/workflows-activities.md)
190
190
  - [Pydantic Integration](../core-features/workflows-activities.md#pydantic-integration)
191
191
  - [Example Code](https://github.com/i2y/edda/blob/main/examples/pydantic_rpc_integration.py)
192
- - [pydantic-rpc](https://github.com/pydantic/pydantic-rpc)
192
+ - [pydantic-rpc](https://github.com/i2y/pydantic-rpc)
193
193
  - [connect-python](https://github.com/connectrpc/connect-python)
@@ -63,6 +63,9 @@ class EddaApp:
63
63
  notify_fallback_interval: int = 30,
64
64
  # Batch processing settings
65
65
  max_workflows_per_batch: int | Literal["auto", "auto:cpu"] = 10,
66
+ # Leader election settings (for coordinating background tasks across workers)
67
+ leader_heartbeat_interval: int = 15,
68
+ leader_lease_duration: int = 45,
66
69
  ):
67
70
  """
68
71
  Initialize Edda application.
@@ -100,6 +103,10 @@ class EddaApp:
100
103
  - int: Fixed batch size (default: 10)
101
104
  - "auto": Scale 10-100 based on queue depth
102
105
  - "auto:cpu": Scale 10-100 based on CPU utilization (requires psutil)
106
+ leader_heartbeat_interval: Interval in seconds for leader heartbeat (default: 15).
107
+ Controls how often workers attempt to become/maintain leadership.
108
+ leader_lease_duration: Duration in seconds for leader lease (default: 45).
109
+ If leader fails to heartbeat within this time, another worker takes over.
103
110
  """
104
111
  self.db_url = db_url
105
112
  self.service_name = service_name
@@ -168,6 +175,12 @@ class EddaApp:
168
175
  "Must be int, 'auto', or 'auto:cpu'."
169
176
  )
170
177
 
178
+ # Leader election settings (for coordinating background tasks)
179
+ self._leader_heartbeat_interval = leader_heartbeat_interval
180
+ self._leader_lease_duration = leader_lease_duration
181
+ self._is_leader = False
182
+ self._leader_tasks: list[asyncio.Task[Any]] = []
183
+
171
184
  def _create_storage(self, db_url: str) -> SQLAlchemyStorage:
172
185
  """
173
186
  Create storage backend from database URL.
@@ -309,19 +322,19 @@ class EddaApp:
309
322
 
310
323
  # Subscribe to workflow resumable notifications
311
324
  await self._notify_listener.subscribe(
312
- "edda_workflow_resumable",
325
+ "workflow_resumable",
313
326
  self._on_workflow_resumable_notify,
314
327
  )
315
328
 
316
329
  # Subscribe to outbox notifications
317
330
  await self._notify_listener.subscribe(
318
- "edda_outbox_pending",
331
+ "workflow_outbox_pending",
319
332
  self._on_outbox_pending_notify,
320
333
  )
321
334
 
322
335
  # Subscribe to timer expired notifications
323
336
  await self._notify_listener.subscribe(
324
- "edda_timer_expired",
337
+ "workflow_timer_expired",
325
338
  self._on_timer_expired_notify,
326
339
  )
327
340
 
@@ -462,47 +475,202 @@ class EddaApp:
462
475
  self._initialized = False
463
476
 
464
477
  def _start_background_tasks(self) -> None:
465
- """Start background maintenance tasks."""
466
- # Task to cleanup stale locks and auto-resume workflows
467
- auto_resume_task = asyncio.create_task(
468
- auto_resume_stale_workflows_periodically(
469
- self.storage,
470
- self.replay_engine,
471
- self.worker_id,
472
- interval=60, # Check every 60 seconds
473
- )
474
- )
475
- self._background_tasks.append(auto_resume_task)
478
+ """Start background maintenance tasks.
476
479
 
477
- # Task to check expired timers and resume workflows
478
- timer_check_task = asyncio.create_task(
479
- self._check_expired_timers_periodically(interval=10) # Check every 10 seconds
480
- )
481
- self._background_tasks.append(timer_check_task)
482
-
483
- # Task to check expired message subscriptions and fail workflows
484
- # Note: CloudEvents timeouts are also handled here since wait_event() uses wait_message()
485
- message_timeout_task = asyncio.create_task(
486
- self._check_expired_message_subscriptions_periodically(
487
- interval=10
488
- ) # Check every 10 seconds
489
- )
490
- self._background_tasks.append(message_timeout_task)
480
+ Background tasks are divided into two categories:
481
+ 1. All-worker tasks: Run on every worker (leader election, workflow resumption)
482
+ 2. Leader-only tasks: Run only on the elected leader (timers, timeouts, cleanup)
483
+
484
+ This design reduces database polling load significantly in multi-worker deployments.
485
+ """
486
+ # Leader election loop (all workers participate)
487
+ leader_election_task = asyncio.create_task(self._leader_election_loop())
488
+ self._background_tasks.append(leader_election_task)
491
489
 
492
- # Task to resume workflows after message delivery (fast resumption)
490
+ # Task to resume workflows after message delivery (all workers - competitive lock)
493
491
  message_resume_task = asyncio.create_task(
494
492
  self._resume_running_workflows_periodically(interval=1) # Check every 1 second
495
493
  )
496
494
  self._background_tasks.append(message_resume_task)
497
495
 
498
- # Task to cleanup old channel messages (orphaned messages)
499
- message_cleanup_task = asyncio.create_task(
500
- self._cleanup_old_messages_periodically(
501
- interval=3600, # Check every 1 hour
502
- retention_days=self._message_retention_days,
496
+ # Note: Leader-only tasks (timer checks, message timeouts, stale workflow cleanup,
497
+ # old message cleanup) are started dynamically in _leader_election_loop() when
498
+ # this worker becomes the leader.
499
+
500
+ async def _leader_election_loop(self) -> None:
501
+ """
502
+ Leader election loop that runs on all workers.
503
+
504
+ Uses system lock to elect a single leader among all workers.
505
+ The leader runs maintenance tasks (timer checks, message timeouts, etc.).
506
+ Non-leaders only participate in workflow resumption.
507
+
508
+ If a leader task crashes, it will be automatically restarted.
509
+ """
510
+ while True:
511
+ try:
512
+ was_leader = self._is_leader
513
+
514
+ # Try to acquire/renew leadership
515
+ self._is_leader = await self.storage.try_acquire_system_lock(
516
+ lock_name="edda_leader",
517
+ worker_id=self.worker_id,
518
+ timeout_seconds=self._leader_lease_duration,
519
+ )
520
+
521
+ if self._is_leader and not was_leader:
522
+ # Became leader - start leader-only tasks
523
+ logger.info(f"Worker {self.worker_id} became leader")
524
+ self._leader_tasks = self._create_leader_only_tasks()
525
+
526
+ elif not self._is_leader and was_leader:
527
+ # Lost leadership - cancel leader-only tasks
528
+ logger.info(f"Worker {self.worker_id} lost leadership")
529
+ await self._cancel_tasks(self._leader_tasks)
530
+ self._leader_tasks = []
531
+
532
+ elif self._is_leader:
533
+ # Still leader - check if any leader tasks have crashed and restart
534
+ await self._monitor_and_restart_leader_tasks()
535
+
536
+ # Wait before next heartbeat
537
+ await asyncio.sleep(self._leader_heartbeat_interval)
538
+
539
+ except asyncio.CancelledError:
540
+ # Shutdown - cancel leader tasks and exit
541
+ await self._cancel_tasks(self._leader_tasks)
542
+ self._leader_tasks = []
543
+ raise
544
+ except Exception as e:
545
+ logger.error(f"Leader election error: {e}", exc_info=True)
546
+ self._is_leader = False
547
+ await self._cancel_tasks(self._leader_tasks)
548
+ self._leader_tasks = []
549
+ # Wait before retry
550
+ await asyncio.sleep(self._leader_heartbeat_interval)
551
+
552
+ def _create_leader_only_tasks(self) -> list[asyncio.Task[Any]]:
553
+ """
554
+ Create tasks that should only run on the leader worker.
555
+
556
+ These tasks are responsible for:
557
+ - Timer expiration checks
558
+ - Message subscription timeout checks
559
+ - Stale workflow auto-resume
560
+ - Old message cleanup
561
+ """
562
+ tasks = []
563
+
564
+ # Timer expiration check
565
+ tasks.append(
566
+ asyncio.create_task(
567
+ self._check_expired_timers_periodically(interval=10),
568
+ name="leader_timer_check",
503
569
  )
504
570
  )
505
- self._background_tasks.append(message_cleanup_task)
571
+
572
+ # Message subscription timeout check
573
+ tasks.append(
574
+ asyncio.create_task(
575
+ self._check_expired_message_subscriptions_periodically(interval=10),
576
+ name="leader_message_timeout_check",
577
+ )
578
+ )
579
+
580
+ # Stale workflow auto-resume
581
+ tasks.append(
582
+ asyncio.create_task(
583
+ auto_resume_stale_workflows_periodically(
584
+ self.storage,
585
+ self.replay_engine,
586
+ self.worker_id,
587
+ interval=60,
588
+ ),
589
+ name="leader_stale_workflow_resume",
590
+ )
591
+ )
592
+
593
+ # Old message cleanup
594
+ tasks.append(
595
+ asyncio.create_task(
596
+ self._cleanup_old_messages_periodically(
597
+ interval=3600,
598
+ retention_days=self._message_retention_days,
599
+ ),
600
+ name="leader_message_cleanup",
601
+ )
602
+ )
603
+
604
+ return tasks
605
+
606
+ async def _cancel_tasks(self, tasks: list[asyncio.Task[Any]]) -> None:
607
+ """Cancel a list of tasks and wait for them to finish."""
608
+ for task in tasks:
609
+ task.cancel()
610
+ await asyncio.gather(*tasks, return_exceptions=True)
611
+
612
+ async def _monitor_and_restart_leader_tasks(self) -> None:
613
+ """
614
+ Monitor leader tasks and restart any that have crashed.
615
+
616
+ This ensures leader-only tasks keep running even if they encounter errors.
617
+ """
618
+ task_creators = {
619
+ "leader_timer_check": lambda: asyncio.create_task(
620
+ self._check_expired_timers_periodically(interval=10),
621
+ name="leader_timer_check",
622
+ ),
623
+ "leader_message_timeout_check": lambda: asyncio.create_task(
624
+ self._check_expired_message_subscriptions_periodically(interval=10),
625
+ name="leader_message_timeout_check",
626
+ ),
627
+ "leader_stale_workflow_resume": lambda: asyncio.create_task(
628
+ auto_resume_stale_workflows_periodically(
629
+ self.storage,
630
+ self.replay_engine,
631
+ self.worker_id,
632
+ interval=60,
633
+ ),
634
+ name="leader_stale_workflow_resume",
635
+ ),
636
+ "leader_message_cleanup": lambda: asyncio.create_task(
637
+ self._cleanup_old_messages_periodically(
638
+ interval=3600,
639
+ retention_days=self._message_retention_days,
640
+ ),
641
+ name="leader_message_cleanup",
642
+ ),
643
+ }
644
+
645
+ # Check each task and restart if done (crashed)
646
+ new_tasks = []
647
+ for task in self._leader_tasks:
648
+ if task.done():
649
+ # Task has finished (possibly due to error)
650
+ task_name = task.get_name()
651
+ try:
652
+ # Check if it raised an exception
653
+ exc = task.exception()
654
+ if exc is not None:
655
+ logger.warning(
656
+ f"Leader task {task_name} crashed with {type(exc).__name__}: {exc}, "
657
+ "restarting..."
658
+ )
659
+ except asyncio.CancelledError:
660
+ # Task was cancelled, don't restart
661
+ logger.debug(f"Leader task {task_name} was cancelled")
662
+ continue
663
+
664
+ # Restart the task
665
+ if task_name in task_creators:
666
+ new_task = task_creators[task_name]()
667
+ new_tasks.append(new_task)
668
+ logger.info(f"Restarted leader task: {task_name}")
669
+ else:
670
+ # Task is still running
671
+ new_tasks.append(task)
672
+
673
+ self._leader_tasks = new_tasks
506
674
 
507
675
  def _auto_register_workflows(self) -> None:
508
676
  """